Introduction
Verified against Minecraft 26.3
Java Minecraft is one codebase that runs as two programs. The server is
the world: a loop that ticks twenty times a second, owns every chunk, entity and block, and decides what they are — save a few things it takes from your client once it has checked them, chief among them where the player you control, and anything it steers, stands. The client is a
window, a loop that draws frames as fast as vsync or the frame-rate limit allow, and a copy of the
world it is told about — which it will happily change on its own and be
overruled about. It ticks too, nought to ten times inside each frame, catching its own copy up to the same twenty-a-second clock and dropping any tick owed beyond ten. They talk over a real Netty connection even when
both run in the same process — in singleplayer the connection never touches
a socket, but the packets are real, and an IntegratedServer is a
MinecraftServer with the client half attached rather than absent. The split is behind some of the first things a player notices: the
client predicts and the server overrules; a chunk exists on the server long
before the client is sent it; a sword swing is a packet, a hit is a reply.
flowchart TD
subgraph Client["Render thread"]
MC["Minecraft: a frame, 0 to 10 ticks in it"]
CL["ClientLevel: the copy it is told about"]
end
subgraph Netty["Netty event loop"]
Conn["Connection: socket or in-process"]
end
subgraph Server["Server thread"]
MS["MinecraftServer: a tick every 50 ms"]
SL["ServerLevel: the world, one per dimension"]
end
subgraph Pool["Worker-Main-n"]
W["generation, lighting, meshing"]
end
MC --> CL
MS --> SL
MC -- "what the player did" --> Conn
Conn -- "what the world became" --> CL
Conn <--> SL
W -. "chunks" .-> SL
W -. "meshes" .-> MC
The two programs and the four threads they run on: the client and the server each own their copy of the world and talk through the connection, and the worker pool hands its results back, dotted, to whichever of them asked.
The whole thing is 7,301 source files and about 740,000 lines of Java 25. Just under a third of those lines is client-only; the rest ships in both jars, and the picture below is the split — orange is the client’s, blue is everything the dedicated server also ships, and the hatched boxes are the packages Part I’s what this book skips tours, most of them what this book leaves out.
The four boxes of the first picture are the four threads that carry nearly all of it — the Render thread, which is also the client’s game thread; the Server thread; the Netty event loop; and a shared worker pool, whose threads are named Worker-Main-n — and the first lecture of the book, Anatomy, is those four threads and the two loops.
How the book is read
This site is the notes for a video lecture series, and that is why it keeps saying watched where a book would say read. One system page is one lecture; the page stands alone and nothing on it leans on the video to make sense, but the order, the scenarios and the figures are all built for someone who will eventually be shown them. The site is that series in three tiers, and the sidebar is its table of contents.
Parts are watched in order. Thirteen of them, I to XIII, each a system: the anatomy of the two programs, the foundations they are both built out of, the server, the world, blocks, entities, items, the player, networking, the client, rendering, world generation, and commands. Each opens on a landing page that argues what its part claims about its system, says what shape the part is, names the parts it assumes and what it uses each of them for, lists its pages in the order to watch them, says where the part stops, and says which Reference pages the part reads. A page is one lecture’s notes: it follows one scenario through the system (a player walks east across a chunk boundary; a server is clicked in the list) and its figure is the artefact — a sequence diagram whose lanes are class names, a state machine, a flowchart of a decision. Every diagram enlarges on click. Each part only assumes the ones before it, and the lecture map says where that is not quite true. The picture below is the whole of that, drawn as the line it nearly is: the parts in watch order, and a solid arc from a part to each part that assumes it. The two dependencies the whole book leans on, whether or not a landing page lists them — Part I for the threads, Part II for codecs and registries — keep their boxes and the one arc each along the spine but have the rest of theirs left off, because those would reach almost every box, and the two dashed arcs are the only places a part assumes a later one, each cut on purpose rather than solved by reordering.
The thirteen parts top to bottom in watch order; an arc on the right runs from a part to one that assumes it, always downward, and the two dashed arcs on the left are the only places a part assumes a later one, each labelled with the pages it needs.
Maps are looked at once. The atlas is four views of the decompile — where the code is, where the mass is, what everything imports and what extends what. Each view’s figure and its table are regenerated on every deploy, so their numbers are re-derived each time; the prose around them is written by hand from those tables, or from the tree where no table holds a number, and is re-read when the version moves. It is the “where is everything” answer a newcomer wants before any system page makes sense.
Reference is looked up. Twenty-three pages, of
which these are the ones a reader reaches for first: the
glossary, the naming
drift table, a class
index that answers “which page talks about
ChunkMap”, the diagram lanes and the
threads. Thirteen of the twenty-three are rewritten from the decompile or from the corpus on every deploy, so their rows are re-derived each time; the other ten are hand-kept, and the shelf’s own
front page says which is which. The rule for what
belongs here is would a viewer pause the video to read this.
For agents, the whole site is one file: llms-full.txt, regenerated on every deploy.
The rules the book keeps
Names, never code. A page names classes, methods, fields and packages so that anyone with the decompiled source can find them in a minute, and explains what they own, when they run and how they interact. It never reproduces the source — not a method body, not a snippet. Anyone who needs the code decompiles the game themselves, which is also the line Mojang’s mappings licence draws.
Mojang’s names. Every game identifier is Mojang’s official mapping, which the
decompile uses. Fabric’s Yarn names differ; where a modder would not
recognise a class under its official name, the Yarn name is noted once.
Names have moved since 1.21 — even since 1.21.11, Lightmap was LightTexture and RedstoneWireBlock was RedStoneWireBlock — and the naming drift table is the translation.
Newest version only. Every page that describes the game says in its header which release it was verified against, or for a generated view which release it was generated from, and the book’s is 26.3. There are no version-difference sections, and the only “in 1.x this was” on a system page is the For a 1.21-era reader note at its foot. When a release lands, every page is re-read against it, and until a page has been, its header still names the release it was checked against.
Verified means tested. Every backticked name on every written page, and every class name of more than one word and every member named with its class or its lane inside a diagram, is checked against the decompile of the release the page’s header names before the site publishes, every diagram is parsed by the same mermaid the site ships, every lane in every diagram is checked against the one key the whole book uses, and every link and anchor between pages is checked to land. A page that fails any of those does not go up, and neither does a change that puts the landing pages, the sidebar, the lecture map, the dependency figure and the Reference shelf’s own index of who uses it out of step with each other. That is a narrow guarantee, and it is worth stating narrowly: it proves the names are real in that release, not that the sentence around them is true. The sentences are what the passes are for. The book has been fact-checked against the decompile three times — as drafted, after the pages were restructured into the book you are reading, and again for this release — and a page written after the first check has had every check since. The first two times every page checked had something wrong on it, and the third found errors again in every part. What is left is a correction waiting to be filed, and the repository is where to file it.
How the system works, not how the code reads. Object-level: this class owns that state, this call happens on that thread, this packet is sent then. Never line-level. Code makes boring video and dates fast.
What this book skips
Most of what the treemap hatches is left out, and it falls into three kinds. Code that is
version differences by definition — save migration, the util/datafix and
util/filefix trees — which rule three excludes on sight. Code that is not
the game — Realms, telemetry, the profiler, the management server, RCON,
the data generators. And code that is the game’s bookkeeping rather than
its behaviour — statistics, player reporting, and a package of id constants
nobody will recognise.
What this book skips, the second
lecture of Part I, draws that boundary honestly — what each thing is, how big, whether the dedicated
server ships it, and the two or three class names to start at if you need
it anyway — so a viewer knows the edge of the map before investing in
thirteen parts. It also tours three things that are not skipped and are only
surprising for where they live: the OpenAL backend, which sits in Blaze3D beside the window and RenderSystem rather than in the client’s sound package; the
recipe book, which sits in the statistics package and belongs to Part VII; and
the debug-drawing API every debug renderer in the game is now written against.
Unofficial, and free to reuse
This is an independent description of how the game works. It is not endorsed by, sponsored by or associated with Mojang Studios or Microsoft, and Minecraft is a trademark of Mojang Synergies AB.
The book is CC BY-SA 4.0: take it, adapt it, teach from it — credit minecraftdocs.dev and keep what you build under the same licence. That covers the writing and the figures, which are the only things here that are anyone’s to license. It does not cover the game, its source, its assets or Mojang’s mappings; none of those are in this book, which is why it names identifiers and never reproduces code. Corrections, and the repository the book is written in, are on GitHub.
The atlas
Verified against Minecraft 26.3 · Maps · Four views of the whole decompile, drawn by
tools/map_source.pyfrom the source tree on every deploy.
Before any system page makes sense you want the answer to a newcomer’s question: where is everything? The atlas is that answer, looked at once. Each map is one or more figures drawn from the decompile, prose saying what they show, and the tables they were drawn from — four figures and two tables on what extends what, one and one on the two bar charts, one and three on where the code is. Take them in any order; none of the four needs another.
No number in the atlas’s figures and tables is counted by hand — they come
from tools/map_source.py, which reads all 7,301 files — but the four pages
are not all one thing, and the distinction matters as much here as on the
Reference shelf. The figures and the tables are
generated: they live in src/generated/, are rewritten on every deploy, and
cannot drift from the source they describe. The prose is typed, and every number quoted in a sentence was typed by a session, read
off one of those tables or counted from the tree where no table holds it. So a
version bump re-derives the tables by itself and leaves the sentences around
them to be re-read, which is what the version pass is for.
flowchart LR
D[("the 26.3 decompile")] --> T["map_source.py, on every deploy"] --> G["seven figures, seven tables"]
G -- "included as they are" --> P["the four map pages"]
G -- "read and typed by a session" --> S["the sentences"] --> P
D -. "or counted from it where no table holds the number" .-> S
The two routes into an atlas page: the figures and tables arrive by the upper one and cannot drift, and the sentences arrive by the lower one, typed by a session, which is the route a version bump has to walk again by hand.
The four maps
| map | the question it answers | the figure |
|---|---|---|
| Where the code is | how big is each package, which jar ships it, and which parts of the book cover it | the jar as a treemap of packages, area by lines |
| Where the mass is | which classes are the largest, and what kind of thing gets that big | the thirty largest classes as bars |
| What everything imports | which Mojang classes the rest of the code cannot be written without, and where the book teaches each | the thirty most-imported Mojang classes as bars |
| What extends what | which inheritance trees are widest, and what shape they are | four trees with the descendant count on every branch |
The treemap is also the book’s picture of the two jars: the introduction uses it to show how much of the code the dedicated server ships, and Part I’s what this book skips uses its hatching to draw the boundary of the book.
How the numbers are counted
The decompile is the client jar, which is a strict superset of the
server jar; beside it sits server-classes.txt, the list of classes the
dedicated server also ships. From those two things every number on these
pages follows.
| number | how it is counted |
|---|---|
| a class | one .java file in the decompile — nested types are not counted as classes, and the 565 four-line package-info.java markers are counted like any other file |
| a line | one line of the decompiled file, blank lines included, so counts are comparable with each other and not with Mojang’s own source |
| client-only | the file is not listed in server-classes.txt; shared means it is |
| fan-in | how many files have a net.minecraft or com.mojang import statement naming the class — the JDK is not counted, and same-package use needs no import, which is what what everything imports then has to allow for |
| descendants | every type reachable from a root through extends and implements, nested types included; a parent written as outer dot inner resolves inside the outer class’s own file, so two types with the same simple name stay two types |
The line counts include what the decompiler adds — braces on their own lines, expanded switches — and a table of constants written a line apiece can be longer than a class that does something. Read length as where the reading is, not where the difficulty is.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Where the code is
Verified against Minecraft 26.3 · Maps · The jar as a treemap of packages: area is lines of decompiled source, colour is which jar ships the package, hatching is the packages Part I’s what this book skips tours.
Java Minecraft is 7,301 classes and 741,069 lines of decompiled Java 25,
and the first surprise in it is how little of that the client owns alone.
Everything a player sees — every screen, the HUD, the whole renderer, the entity models, both
GPU back-ends, the sound engine, Realms — is the orange near-third of this
picture. The blue seven tenths ship in both jars, and the biggest inner
box, world/level, is a fifth of the game by itself: blocks, block
states, chunks, lighting and world generation, all of it code the
dedicated server runs with no window attached.
net/minecraft or com/mojang, labelled with its share of the lines where the box has room for it; the boxes inside are its sub-packages. Hover a box for its counts; click the figure to enlarge it.Two jars, one tree
The client jar is a strict superset of the server jar, and the split is
clean: the 2,264 client-only classes live in exactly five packages —
net/minecraft/client (1,891 classes), com/mojang/renderpearl (146),
com/mojang/realmsclient (137), com/mojang/blaze3d (86) and net/minecraft/realms (4). Every other
package in the table below ships in both jars, class for class; no
sub-package at this depth is mixed. The client-only side is 220,818
lines, 29.8% of the total, which is the “just under a third” the
introduction quotes.
The consequence for reading this book: when a page says a class is
client-only it is saying which package the class lives in, and every
package Parts IV and V own ships in both jars. The oracle for which side a
class is on is server-classes.txt.
What the big boxes are
world — 42%. world/level (151,146 lines, 1,418 classes) is Parts
IV, V and XII: chunks, block states, lighting and, under
world/level/levelgen, most of world generation — world/level/biome is
its sibling rather than part of it. world/entity
(110,760 lines, 727 classes) is Parts VI and VIII: the entity hierarchy,
its AI and the player. world/item and world/inventory (37,813 lines
between them) are Part VII.
client — 24%. client/gui (59,594 lines) is the screens and the HUD,
Part X; client/renderer and client/model (63,361 lines together) are
the frame, section meshing and the entity models, Part XI;
client/multiplayer (11,303) holds ClientPacketListener and
ClientLevel, the client’s copy of the world. The 41 classes directly in
net/minecraft/client — Minecraft, Options, KeyMapping,
MouseHandler — are 10,821 lines on their own. (That 41 is the itself only
count defined below; the 1,891 above is the package with every sub-package
under it.)
util — 7%, two thirds of it skipped. util/datafix, util/filefix
and util/profiling are 34,866 of the package’s 54,700 lines and are all
outside this book; the hatched boxes are those three, and much the largest of
them is the save-migration history. What is
left is the toolbox every part uses — Mth, RandomSource, Util — and
the parsing and debug packages.
server — 7%. server/commands (13,357) is Part XIII’s command
implementations; server/level is 42 classes and 12,166 lines — 290 lines a class, nearly
three times the map’s average, in a package small enough to list
(ServerLevel, ChunkMap, ServerPlayer), Parts III and IV; server/network is the serverbound
packet handlers, Part IX; server/packs is the pack system, Part II;
server/jsonrpc, the management server, is skipped.
renderpearl and blaze3d — 4%. The GPU abstraction, com/mojang/renderpearl, has
two back-ends behind GpuDevice, and the Vulkan one (renderpearl/backend/vulkan,
7,387 lines) is larger than the OpenGL one (renderpearl/backend/opengl, 5,815);
com/mojang/blaze3d keeps the window, the frame graph, the vertex formats and
RenderSystem.
network — 3%, in 416 classes. network/protocol is 298 classes in
12,872 lines: the packet catalogue is many tiny classes, one per packet.
network/chat (4,799) is Component and chat signing.
Below 3% the other boxes include Part II’s foundations — net/minecraft/core,
net/minecraft/nbt, net/minecraft/tags, net/minecraft/resources — and
Part XIII’s net/minecraft/commands (the command source, the argument types
and the execution engine) and net/minecraft/advancements, plus
com/mojang/realmsclient and the skipped net/minecraft/data. That last one
is the program that writes the vanilla data pack — and it is not build-time
only: the dedicated server ships all 174 classes, and Blocks and
MinecraftServer both read data/worldgen constants at run time (what this
book skips sorts every such
reader into three kinds).
Where each part lives
The parts follow the tree, but not one box each. This table is the map
from the book’s order to the jar’s, and it is the one table on this page
that is a decision rather than a measurement: the packages are assigned by
hand, in the tool that draws the atlas, and the counts follow. A package
under net/minecraft is written without that prefix; itself only means
the files directly in the package and not its sub-packages; a package two
parts share is counted in both and a file in no part’s packages in none, so
the total row is not the jar’s. A landing page that states its part’s size
quotes its own row of this table, and the hatched packages and player reporting are left out of every row.
| part | packages | classes | client-only | lines |
|---|---|---|---|---|
| I · Anatomy | client/main, Minecraft, Main, MinecraftServer | 7 | 5 | 6,817 |
| II · Foundations | core, resources, tags, nbt, server/packs, util, world/flag | 468 | 0 | 48,809 |
| III · The server | server (itself only), server/level, server/players, server/dedicated | 95 | 0 | 21,900 |
| IV · The world | world/level/chunk, world/level/lighting, world/ticks, world/level/gameevent, world/level/entity, world/level/material, world/attribute, world/timeline, world/clock, world/level/border, server/level, world/level/blockscan | 229 | 0 | 31,913 |
| V · Blocks | world/level/block, world/level/redstone | 484 | 0 | 58,507 |
| VI · Entities | world/entity, network/syncher, world/level/pathfinder, world/damagesource, world/effect, minus world/entity/player | 766 | 0 | 112,091 |
| VII · Items and inventories | world/item, world/inventory, world/level/storage/loot | 581 | 0 | 49,245 |
| VIII · The player | world/entity/player, world/food, ServerPlayer, client/player | 32 | 11 | 8,460 |
| IX · Networking | network, server/network, client/multiplayer, minus network/syncher | 490 | 51 | 38,711 |
| X · The client | client (itself only), client/gui, client/multiplayer, client/sounds, client/resources, client/player, client/input, client/server | 689 | 689 | 94,527 |
| XI · Rendering | client/renderer, client/model, client/particle, com/mojang/blaze3d, com/mojang/renderpearl | 1,291 | 1,291 | 98,283 |
| XII · World generation | world/level/levelgen, world/level/biome | 477 | 0 | 49,303 |
| XIII · Commands and data packs | commands, server/commands, server/dialog, server/permissions, server/bossevents, advancements, gametest, world/scores, client/gui/screens/dialog | 481 | 17 | 44,083 |
| the thirteen parts, with the shared packages counted twice | 6,090 | 2,064 | 662,649 |
The table
Depth three is the outer boxes of the treemap; depth four is the inner
ones. client-only counts the classes not in server-classes.txt.
| package (depth 3) | classes | client-only | lines |
|---|---|---|---|
net/minecraft/world | 2715 | 0 | 313,098 |
net/minecraft/client | 1891 | 1891 | 176,818 |
net/minecraft/util | 735 | 0 | 54,700 |
net/minecraft/server | 432 | 0 | 53,789 |
net/minecraft/network | 416 | 0 | 23,319 |
com/mojang/renderpearl | 146 | 146 | 18,659 |
net/minecraft/data | 174 | 0 | 16,849 |
com/mojang/realmsclient | 137 | 137 | 14,701 |
net/minecraft/commands | 124 | 0 | 13,229 |
net/minecraft/core | 123 | 0 | 12,359 |
com/mojang/blaze3d | 86 | 86 | 10,437 |
net/minecraft/nbt | 43 | 0 | 7,791 |
net/minecraft/advancements | 115 | 0 | 7,647 |
net/minecraft/gametest | 48 | 0 | 5,756 |
net/minecraft/sounds | 6 | 0 | 2,124 |
net/minecraft/tags | 29 | 0 | 1,874 |
net/minecraft | 19 | 0 | 1,665 |
net/minecraft/references | 5 | 0 | 1,483 |
net/minecraft/resources | 12 | 0 | 1,473 |
com/mojang/math | 10 | 0 | 1,054 |
net/minecraft/stats | 10 | 0 | 874 |
net/minecraft/gizmos | 15 | 0 | 569 |
net/minecraft/recipebook | 3 | 0 | 351 |
net/minecraft/locale | 3 | 0 | 247 |
net/minecraft/realms | 4 | 4 | 203 |
| total | 7301 | 2264 | 741,069 |
| package (depth 4) | classes | client-only | lines |
|---|---|---|---|
net/minecraft/world/level | 1418 | 0 | 151,146 |
net/minecraft/world/entity | 727 | 0 | 110,760 |
net/minecraft/client/gui | 445 | 445 | 59,594 |
net/minecraft/client/renderer | 715 | 715 | 50,649 |
net/minecraft/world/item | 323 | 0 | 30,661 |
net/minecraft/util/datafix | 411 | 0 | 27,048 |
com/mojang/renderpearl/backend | 76 | 76 | 13,866 |
net/minecraft/server/commands | 111 | 0 | 13,357 |
net/minecraft/network/protocol | 298 | 0 | 12,872 |
net/minecraft/util | 93 | 0 | 12,767 |
net/minecraft/client/model | 268 | 268 | 12,712 |
net/minecraft/server/level | 42 | 0 | 12,166 |
net/minecraft/client/multiplayer | 63 | 63 | 11,303 |
net/minecraft/client | 41 | 41 | 10,821 |
net/minecraft/commands/arguments | 73 | 0 | 9,017 |
net/minecraft/client/resources | 110 | 110 | 8,326 |
net/minecraft/nbt | 36 | 0 | 7,279 |
net/minecraft/world/inventory | 64 | 0 | 7,152 |
com/mojang/realmsclient/gui | 46 | 46 | 7,091 |
net/minecraft/client/particle | 88 | 88 | 6,839 |
net/minecraft/client/data | 28 | 28 | 6,189 |
net/minecraft/data/worldgen | 64 | 0 | 6,016 |
net/minecraft/gametest/framework | 46 | 0 | 5,737 |
net/minecraft/server/network | 29 | 0 | 5,566 |
net/minecraft/core | 39 | 0 | 5,499 |
net/minecraft/server | 27 | 0 | 5,153 |
net/minecraft/server/packs | 57 | 0 | 5,122 |
net/minecraft/network/chat | 63 | 0 | 4,799 |
com/mojang/blaze3d/platform | 26 | 26 | 4,382 |
net/minecraft/util/profiling | 70 | 0 | 4,265 |
net/minecraft/server/jsonrpc | 65 | 0 | 4,118 |
net/minecraft/network | 42 | 0 | 3,590 |
net/minecraft/util/filefix | 57 | 0 | 3,553 |
net/minecraft/advancements/predicates | 53 | 0 | 3,450 |
net/minecraft/core/component | 32 | 0 | 3,067 |
net/minecraft/world/phys | 28 | 0 | 3,036 |
net/minecraft/data/loot | 21 | 0 | 3,023 |
com/mojang/renderpearl/frontend | 12 | 12 | 2,876 |
net/minecraft/advancements/triggers | 49 | 0 | 2,816 |
net/minecraft/server/players | 19 | 0 | 2,770 |
com/mojang/realmsclient/client | 19 | 19 | 2,643 |
net/minecraft/data/recipes | 20 | 0 | 2,507 |
com/mojang/blaze3d/vertex | 14 | 14 | 2,322 |
net/minecraft/client/sounds | 17 | 17 | 2,174 |
net/minecraft/sounds | 6 | 0 | 2,124 |
net/minecraft/client/player | 11 | 11 | 2,072 |
com/mojang/realmsclient/dto | 43 | 43 | 1,986 |
net/minecraft/world/attribute | 30 | 0 | 1,936 |
net/minecraft/commands | 14 | 0 | 1,921 |
com/mojang/renderpearl/api | 53 | 53 | 1,879 |
net/minecraft/util/parsing | 29 | 0 | 1,879 |
net/minecraft/tags | 29 | 0 | 1,874 |
net/minecraft/server/dedicated | 7 | 0 | 1,811 |
net/minecraft/data/tags | 29 | 0 | 1,756 |
net/minecraft/world | 25 | 0 | 1,686 |
net/minecraft | 19 | 0 | 1,665 |
net/minecraft/network/codec | 7 | 0 | 1,533 |
net/minecraft/core/registries | 14 | 0 | 1,525 |
com/mojang/realmsclient | 4 | 4 | 1,492 |
net/minecraft/references | 5 | 0 | 1,483 |
net/minecraft/resources | 12 | 0 | 1,473 |
net/minecraft/world/scores | 16 | 0 | 1,442 |
com/mojang/realmsclient/util | 20 | 20 | 1,425 |
net/minecraft/advancements | 13 | 0 | 1,381 |
net/minecraft/util/debug | 19 | 0 | 1,348 |
net/minecraft/world/effect | 20 | 0 | 1,306 |
net/minecraft/data | 10 | 0 | 1,272 |
net/minecraft/client/telemetry | 18 | 18 | 1,221 |
net/minecraft/core/dispenser | 14 | 0 | 1,098 |
com/mojang/math | 10 | 0 | 1,054 |
com/mojang/blaze3d/audio | 12 | 12 | 1,013 |
net/minecraft/util/worldupdate | 6 | 0 | 958 |
net/minecraft/world/damagesource | 12 | 0 | 936 |
net/minecraft/commands/synchronization | 12 | 0 | 905 |
net/minecraft/data/advancements | 10 | 0 | 896 |
net/minecraft/client/server | 6 | 6 | 885 |
net/minecraft/server/dialog | 35 | 0 | 885 |
net/minecraft/client/color | 17 | 17 | 879 |
net/minecraft/stats | 10 | 0 | 874 |
net/minecraft/world/ticks | 14 | 0 | 869 |
net/minecraft/core/particles | 21 | 0 | 863 |
net/minecraft/util/thread | 9 | 0 | 842 |
net/minecraft/server/rcon | 9 | 0 | 837 |
com/mojang/blaze3d/systems | 5 | 5 | 773 |
net/minecraft/commands/execution | 18 | 0 | 756 |
net/minecraft/util/valueproviders | 19 | 0 | 707 |
net/minecraft/world/waypoints | 9 | 0 | 677 |
net/minecraft/client/tutorial | 10 | 10 | 671 |
net/minecraft/commands/functions | 7 | 0 | 630 |
net/minecraft/gizmos | 15 | 0 | 569 |
net/minecraft/network/syncher | 6 | 0 | 525 |
net/minecraft/client/main | 4 | 4 | 523 |
net/minecraft/data/info | 8 | 0 | 518 |
net/minecraft/nbt/visitors | 7 | 0 | 512 |
net/minecraft/client/animation | 23 | 23 | 509 |
net/minecraft/client/searchtree | 8 | 8 | 505 |
net/minecraft/data/structures | 5 | 0 | 473 |
net/minecraft/util/eventlog | 4 | 0 | 459 |
net/minecraft/world/timeline | 5 | 0 | 446 |
com/mojang/blaze3d/framegraph | 3 | 3 | 437 |
com/mojang/blaze3d/font | 7 | 7 | 432 |
net/minecraft/server/permissions | 12 | 0 | 402 |
net/minecraft/world/clock | 11 | 0 | 390 |
net/minecraft/util/random | 4 | 0 | 374 |
com/mojang/blaze3d/pipeline | 5 | 5 | 366 |
net/minecraft/world/flag | 7 | 0 | 356 |
net/minecraft/server/chase | 3 | 0 | 354 |
net/minecraft/recipebook | 3 | 0 | 351 |
net/minecraft/server/notifications | 5 | 0 | 343 |
net/minecraft/server/bossevents | 3 | 0 | 328 |
net/minecraft/data/registries | 5 | 0 | 325 |
net/minecraft/server/gui | 4 | 0 | 310 |
net/minecraft/core/cauldron | 3 | 0 | 307 |
net/minecraft/client/input | 8 | 8 | 304 |
net/minecraft/world/food | 6 | 0 | 299 |
com/mojang/blaze3d | 5 | 5 | 289 |
net/minecraft/client/quickplay | 3 | 3 | 284 |
net/minecraft/locale | 3 | 0 | 247 |
com/mojang/blaze3d/buffers | 3 | 3 | 228 |
net/minecraft/client/entity | 4 | 4 | 228 |
net/minecraft/realms | 4 | 4 | 203 |
net/minecraft/util/debugchart | 8 | 0 | 199 |
com/mojang/blaze3d/resource | 6 | 6 | 195 |
net/minecraft/util/context | 4 | 0 | 181 |
net/minecraft/server/waypoints | 2 | 0 | 180 |
net/minecraft/util/monitoring | 2 | 0 | 120 |
net/minecraft/server/advancements | 2 | 0 | 87 |
net/minecraft/client/profiling | 2 | 2 | 81 |
com/mojang/realmsclient/exception | 5 | 5 | 64 |
net/minecraft/data/metadata | 2 | 0 | 63 |
net/minecraft/client/waypoints | 2 | 2 | 49 |
com/mojang/renderpearl/util | 4 | 4 | 34 |
net/minecraft/gametest | 2 | 0 | 19 |
com/mojang/renderpearl | 1 | 1 | 4 |
| total | 7301 | 2264 | 741,069 |
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Where the mass is
Verified against Minecraft 26.3 · Maps · The forty largest classes by lines of decompiled source, the top thirty of them drawn, coloured by which jar ships them.
The two largest classes in the game are the top of one hierarchy: Entity
and LivingEntity, 9,078 lines between them, the thing every mob and
player is before it is anything else. Third is Minecraft, the client
itself. And then the list turns odd: two of the top ten are not game logic
at all. BlockModelGenerators runs only inside the data generator — its
single caller is ModelProvider — and writes the block model JSON that ships
in the jar; BlockStateData is a table that the save-migration fixes consult
when data a much older version saved is read, and nothing outside
util/datafix reads it. Size is where the reading is,
not where the game is.
net/minecraft, or under com/mojang where it starts mojang/. Click to enlarge.64,082 lines are in these thirty classes, 8.6% of the game in 0.4% of its files.
Three kinds of big
Read down the bars and twenty-four of the thirty sort themselves into three kinds, and the kind tells you how to read the class.
The god objects. Entity and LivingEntity are the trunk, each over four
thousand lines because each is the base of everything below it. The
fork below them is not one chain: Mob is one of LivingEntity‘s direct
subclasses and Avatar is another, with Player under it
and ServerPlayer and LocalPlayer its two leaves in the thirty
(entity anatomy has the tree). Those
two leaves are long for the opposite reason to the trunk: no named class
descends from either, and each is a whole side of the authority split written out.
Minecraft and MinecraftServer are the two programs’ roots; ServerLevel
is the world; ChunkMap is the world’s loader. Only two concrete mobs make
the thirty — Fox and Bee, the two with the most bespoke behaviour in their own files — and
what they are long with is per-species goal classes, which is exactly why
Part VI explains the machine and declines to
enumerate its instances: there is no Fox page and there is not going to be
one.
The switchboards. ClientPacketListener is a handler method for every
clientbound play packet, and ServerGamePacketListenerImpl is the same for
every serverbound one — all but three of the packets both phases share are
handled one class up, in ClientCommonPacketListenerImpl and
ServerCommonPacketListenerImpl; between them they are the whole of what play
can say, which is why Part IX’s pages keep coming back to them.
FriendlyByteBuf is the buffer their packets are read from.
The catalogues written as code. SoundEvents, Blocks and Items are
registries populated a constant at a time, and CreativeModeTabs lists every
tab’s items a line apiece;
DataFixers is the migration history; OceanMonumentPieces and StrongholdPieces are
structures built by hand, room by room, in Java rather than in a template;
Options is every setting in options.txt, and Hud is the in-world overlay
— the crosshair, the hotbar, the bars — inside the wider Gui that also
draws screens, toasts and the loading overlay. These are long because they are lists. None is hard.
The table
Forty rows, of which the figure draws thirty. side is which jar ships the class.
| class | lines | side |
|---|---|---|
net/minecraft/world/entity/Entity | 4,617 | shared |
net/minecraft/world/entity/LivingEntity | 4,461 | shared |
net/minecraft/client/Minecraft | 3,345 | client |
net/minecraft/client/data/models/BlockModelGenerators | 3,136 | client |
net/minecraft/client/multiplayer/ClientPacketListener | 3,090 | client |
net/minecraft/server/MinecraftServer | 2,608 | shared |
net/minecraft/server/network/ServerGamePacketListenerImpl | 2,567 | shared |
net/minecraft/server/level/ServerPlayer | 2,516 | shared |
net/minecraft/util/datafix/fixes/BlockStateData | 2,270 | shared |
net/minecraft/server/level/ServerLevel | 2,139 | shared |
net/minecraft/world/item/Items | 2,067 | shared |
net/minecraft/world/level/block/Blocks | 2,061 | shared |
net/minecraft/world/entity/player/Player | 2,034 | shared |
net/minecraft/sounds/SoundEvents | 2,023 | shared |
net/minecraft/client/Options | 2,010 | client |
net/minecraft/world/level/levelgen/structure/structures/OceanMonumentPieces | 1,983 | shared |
net/minecraft/world/item/CreativeModeTabs | 1,737 | shared |
net/minecraft/server/level/ChunkMap | 1,661 | shared |
net/minecraft/util/datafix/DataFixers | 1,637 | shared |
net/minecraft/world/entity/animal/fox/Fox | 1,625 | shared |
net/minecraft/world/entity/Mob | 1,616 | shared |
net/minecraft/client/renderer/LevelRenderer | 1,499 | client |
net/minecraft/client/gui/Hud | 1,481 | client |
net/minecraft/world/entity/animal/bee/Bee | 1,450 | shared |
net/minecraft/world/level/levelgen/structure/structures/StrongholdPieces | 1,436 | shared |
net/minecraft/client/player/LocalPlayer | 1,435 | client |
net/minecraft/network/FriendlyByteBuf | 1,414 | shared |
net/minecraft/data/loot/packs/VanillaBlockLoot | 1,412 | shared |
net/minecraft/world/level/block/state/BlockBehaviour | 1,380 | shared |
net/minecraft/util/Util | 1,372 | shared |
net/minecraft/gametest/framework/GameTestHelper | 1,369 | shared |
com/mojang/realmsclient/RealmsMainScreen | 1,344 | client |
net/minecraft/client/multiplayer/ClientLevel | 1,334 | client |
net/minecraft/world/level/levelgen/structure/structures/NetherFortressPieces | 1,317 | shared |
net/minecraft/world/item/ItemStack | 1,274 | shared |
net/minecraft/world/level/levelgen/structure/structures/WoodlandMansionPieces | 1,185 | shared |
net/minecraft/world/entity/animal/panda/Panda | 1,121 | shared |
net/minecraft/world/entity/animal/equine/AbstractHorse | 1,115 | shared |
net/minecraft/client/gui/screens/inventory/CreativeModeInventoryScreen | 1,107 | client |
net/minecraft/world/entity/monster/cubemob/SulfurCube | 1,083 | shared |
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
What everything imports
Verified against Minecraft 26.3 · Maps · The thirty most-imported Mojang classes: how many files name each one in an import statement.
One file in six imports BlockPos. That is the least surprising fact on
this page; the next one is not. The chart counts Mojang imports only —
net.minecraft and com.mojang — because the JDK’s List and the
nullability annotation outrank everything on it and say nothing about the
game. Under that rule the second most-imported type is not
Minecraft’s: it is Codec, from Mojang’s DataFixerUpper
library, imported by 943 files, with MapCodec sixth and
RecordCodecBuilder seventh. Three of the seven classes the game most depends
on are the serialisation vocabulary that turns objects into NBT and JSON
and back, which is why Part II teaches codecs before anything a player can
see.
Seven ideas, and where the book teaches them
The hubs are not thirty ideas; twenty-five of them are seven. The table below groups them,
reading down the sixty rows rather than the thirty the figure draws, so that a
family is not broken in half by the cut-off — ByteBufCodecs and PacketType
are the wire’s vocabulary whether or not they clear it. So the table holds
thirty-two names: twenty-five of the thirty, plus seven more from ranks
31 to 60 that their families would be incomplete without. The five of the
thirty it leaves out are the two paragraphs after it, two and three.
Part II teaches three of the seven, Reference two, Part IX one, and the seventh — the world’s nouns — is the rest of the book. That is the more useful fact than Part II teaches the vocabulary: the classes a reader must know are not one part’s worth.
| idea | the hubs | where the book teaches it |
|---|---|---|
| a position | BlockPos, Vec3, Direction, Mth | Math and primitives |
| a name and a registry | Identifier, ResourceKey, Registries, BuiltInRegistries, Holder | Identifiers and registries |
| a shape on disk | Codec, MapCodec, RecordCodecBuilder | Codecs, NBT and JSON, and for MapCodec the data-driven type pattern |
| a shape on the wire | StreamCodec, ByteBufCodecs, RegistryFriendlyByteBuf, Packet, PacketType | Packets and stream codecs |
| text | Component | Text components |
| chance | RandomSource | Math and primitives |
| the world’s nouns | Level, ServerLevel, BlockState, Block, Blocks, Entity, LivingEntity, Player, EntityType, ItemStack, SoundEvents, SoundEvent, DataComponents | Parts II to X |
Two of the thirty belong to no family and get no row above, and neither
does the first class after them; each is worth a look. Minecraft
(284) is thirty-first, the first client-only class on the list and just outside the figure —
the client’s hub is a hub for the client-only side alone, which is under a
third of the files, and the shared seven tenths never name it. And Schema
(404) and DSL (289) are the migration tree talking to itself: all but ten of
the files that import Schema are in util/datafix, and the ten are its
sibling util/filefix. One pair inside the table earns the same second
look: ServerLevel (735) is imported by nearly as many files as Level
(761), because most code that touches the world knows it is on the server and
says so in its types.
The remaining three of the thirty have no row either.
LogUtils (488) is Mojang’s logging library, the one line at the top of a
class that keeps a logger. Util (457) is where the executors and the
static odds and ends live (threads) —
a hub because it is where the unclassifiable went, not because it is an idea.
And BlockBehaviour (313) is the world’s nouns seen one class higher — the
same one-class-higher shape what extends what draws for
the Block tree.
What the count misses
An import is counted once per file, so the chart says how many files
name the class, not how often. It also undercounts every class used
inside its own package, because that needs no import: the 260 files that
import Block are the files outside world/level/block that use it, and
the same is true of BlockBehaviour and Minecraft. A class’s true
reach is this number plus its package.
The table
Sixty rows, of which the figure draws thirty. side is which jar ships the class, or library for a class outside the decompile.
| class | package | imported by | side |
|---|---|---|---|
BlockPos | net.minecraft.core | 1229 | shared |
Codec | com.mojang.serialization | 943 | library |
Identifier | net.minecraft.resources | 914 | shared |
BlockState | net.minecraft.world.level.block.state | 864 | shared |
Holder | net.minecraft.core | 826 | shared |
MapCodec | com.mojang.serialization | 806 | library |
RecordCodecBuilder | com.mojang.serialization.codecs | 797 | library |
RandomSource | net.minecraft.util | 793 | shared |
Component | net.minecraft.network.chat | 777 | shared |
Level | net.minecraft.world.level | 761 | shared |
ServerLevel | net.minecraft.server.level | 735 | shared |
Mth | net.minecraft.util | 703 | shared |
ItemStack | net.minecraft.world.item | 691 | shared |
Entity | net.minecraft.world.entity | 668 | shared |
Vec3 | net.minecraft.world.phys | 585 | shared |
Direction | net.minecraft.core | 584 | shared |
StreamCodec | net.minecraft.network.codec | 547 | shared |
LivingEntity | net.minecraft.world.entity | 536 | shared |
LogUtils | com.mojang.logging | 488 | library |
Registries | net.minecraft.core.registries | 473 | shared |
Util | net.minecraft.util | 457 | shared |
Player | net.minecraft.world.entity.player | 445 | shared |
ResourceKey | net.minecraft.resources | 419 | shared |
Schema | com.mojang.datafixers.schemas | 404 | library |
ByteBufCodecs | net.minecraft.network.codec | 327 | shared |
SoundEvents | net.minecraft.sounds | 320 | shared |
BlockBehaviour | net.minecraft.world.level.block.state | 313 | shared |
Blocks | net.minecraft.world.level.block | 299 | shared |
Packet | net.minecraft.network.protocol | 290 | shared |
DSL | com.mojang.datafixers | 289 | library |
Minecraft | net.minecraft.client | 284 | client |
EntityType | net.minecraft.world.entity | 263 | shared |
Block | net.minecraft.world.level.block | 260 | shared |
DataComponents | net.minecraft.core.component | 258 | shared |
PacketType | net.minecraft.network.protocol | 245 | shared |
RegistryFriendlyByteBuf | net.minecraft.network | 239 | shared |
Dynamic | com.mojang.serialization | 235 | library |
SoundEvent | net.minecraft.sounds | 235 | shared |
ServerPlayer | net.minecraft.server.level | 231 | shared |
BuiltInRegistries | net.minecraft.core.registries | 230 | shared |
LevelReader | net.minecraft.world.level | 228 | shared |
BlockGetter | net.minecraft.world.level | 217 | shared |
Items | net.minecraft.world.item | 215 | shared |
SoundSource | net.minecraft.sounds | 213 | shared |
Pair | com.mojang.datafixers.util | 212 | library |
VoxelShape | net.minecraft.world.phys.shapes | 210 | shared |
EntityTypes | net.minecraft.world.entity | 205 | shared |
Item | net.minecraft.world.item | 204 | shared |
GuiGraphicsExtractor | net.minecraft.client.gui | 200 | client |
Registry | net.minecraft.core | 199 | shared |
BlockTags | net.minecraft.tags | 198 | shared |
LootContext | net.minecraft.world.level.storage.loot | 196 | shared |
ExtraCodecs | net.minecraft.util | 192 | shared |
PoseStack | com.mojang.blaze3d.vertex | 192 | client |
ModelPart | net.minecraft.client.model.geom | 191 | client |
InteractionResult | net.minecraft.world | 190 | shared |
CommandSyntaxException | com.mojang.brigadier.exceptions | 188 | library |
CommandSourceStack | net.minecraft.commands | 188 | shared |
ValueOutput | net.minecraft.world.level.storage | 186 | shared |
TypeRewriteRule | com.mojang.datafixers | 186 | library |
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
What extends what
Verified against Minecraft 26.3 · Maps · The widest inheritance trees, four of them drawn with the number of descendants on every branch.
Block has 297 descendants and Entity 192 — descendants, not direct
subclasses, which is the count both tables below use — and those are the two
trees a reader of this book will climb most often. But neither is the widest
hierarchy in the game. That is FeatureElement, with 386 descendants from
seven implementers — and it is not a hierarchy at all. It is an
interface for anything that can be put behind a feature flag, and its
seven implementers are BlockBehaviour, Item, EntityType, MenuType,
MobEffect, Potion and GameRule, so its descendant count sweeps up the
whole block tree and the whole item tree at once. ItemLike, second at 366, is Block plus
Item. The interface table is a list of which mix-ins reach furthest; the
class table is where the real trees are, and four of them are drawn below.
Entity
Entity tree to three levels. The number is how many types descend from the node; subclasses with no subclasses of their own are folded into one italic line per parent, or named where a parent has only one. Click to enlarge.The shape is a spine with a few branches. LivingEntity holds 124 of the
192, Mob 114 of those, PathfinderMob 108 of those: nearly every mob is four
classes deep before it is a species, and Part VI’s
entity anatomy is that spine.
The non-living entities are two families and a scattering — Projectile
(26) and VehicleEntity (15), then hanging things, displays, and
thirteen direct subclasses of Entity with no children of their own, from
ItemEntity to LightningBolt.
Block
Block tree to three levels; 61 of its 92 direct subclasses have no subclasses of their own and are folded into the last line. Click to enlarge.Block is wide and shallow: 92 direct subclasses, most of them terminal.
The one deep branch is BaseEntityBlock (64), the abstract base most of the
blocks that own a block entity extend — the interface that actually decides is
EntityBlock, and two blocks implement it without taking that base at all,
ComparatorBlock and JigsawBlock. Part V’s
block entities
is the branch in prose.
The table’s first row, BlockBehaviour at 298, is the same tree seen from
one class higher — Block is its only subclass, and it exists so that a
block’s behaviour and its registry identity can be separate classes.
Item
Item tree to three levels. Click to enlarge.Sixty-seven types descend from Item, for over a thousand registered items. The tree is
small because an item’s behaviour mostly is not in its class: what a stack
does is in its data components, and Items registers most of the game
as a plain Item or BlockItem with an Item.Properties describing it. Part VII’s
items and stacks is why the tree
is this shape.
Screen
Screen tree to three levels; 59 of its 71 direct subclasses have no subclasses of their own. Click to enlarge.Screen (161) is Block’s shape again — 71 direct subclasses, 59 of
them terminal — with one deep branch, AbstractContainerScreen (27), the
screens that show a menu (GUI and
screens is Part X’s), and one branch that
is not in this book, RealmsScreen (27). The row above it in the table,
AbstractContainerEventHandler at 162, is Screen’s parent with Screen
as its only subclass, the same one-class-higher effect as
BlockBehaviour.
Two trees the table shows and the figures do not
Goal has 202 descendants from 99 direct subclasses, and 132 of the 202
are nested classes, all but one inside the mob they serve — a fox’s own goals
are declared in Fox, not in world/entity/ai/goal. Packet is an interface with 240 descendants from 229
direct implementers, and almost nothing below them: the packet catalogue is
flat, and the packets reference is its list.
The tables
Class roots first, then interface roots; each table is the thirty roots of its kind with the most descendants. direct is the number of immediate subclasses or implementers; where is the package of the root.
| root | descendants | direct | kind | where |
|---|---|---|---|---|
BlockBehaviour | 298 | 1 | class | net/minecraft/world/level/block/state |
Block | 297 | 92 | class | net/minecraft/world/level/block |
Goal | 202 | 99 | class | net/minecraft/world/entity/ai/goal |
Entity | 192 | 18 | class | net/minecraft/world/entity |
Model | 170 | 14 | class | net/minecraft/client/model |
AbstractContainerEventHandler | 162 | 1 | class | net/minecraft/client/gui/components/events |
Screen | 161 | 71 | class | net/minecraft/client/gui/screens |
EntityModel | 153 | 71 | class | net/minecraft/client/model |
EntityRenderer | 133 | 28 | class | net/minecraft/client/renderer/entity |
LivingEntity | 124 | 3 | class | net/minecraft/world/entity |
Mob | 114 | 5 | class | net/minecraft/world/entity |
PathfinderMob | 108 | 5 | class | net/minecraft/world/entity |
NamespacedSchema | 107 | 107 | class | net/minecraft/util/datafix/schemas |
EntityRenderState | 99 | 23 | class | net/minecraft/client/renderer/entity/state |
AbstractWidget | 99 | 12 | class | net/minecraft/client/gui/components |
LivingEntityRenderer | 96 | 3 | class | net/minecraft/client/renderer/entity |
MobRenderer | 93 | 39 | class | net/minecraft/client/renderer/entity |
Particle | 84 | 4 | class | net/minecraft/client/particle |
SingleQuadParticle | 74 | 46 | class | net/minecraft/client/particle |
LivingEntityRenderState | 70 | 44 | class | net/minecraft/client/renderer/entity/state |
Item | 67 | 49 | class | net/minecraft/world/item |
BaseEntityBlock | 64 | 40 | class | net/minecraft/world/level/block |
StructurePiece | 63 | 8 | class | net/minecraft/world/level/levelgen/structure |
Entry | 63 | 2 | class | net/minecraft/client/gui/components |
Behavior | 58 | 53 | class | net/minecraft/world/entity/ai/behavior |
BlockEntity | 52 | 35 | class | net/minecraft/world/level/block/entity |
AgeableMob | 52 | 4 | class | net/minecraft/world/entity |
RenderLayer | 48 | 39 | class | net/minecraft/client/renderer/entity/layers |
NamedEntityFix | 47 | 47 | class | net/minecraft/util/datafix/fixes |
AgeableMobRenderer | 45 | 26 | class | net/minecraft/client/renderer/entity |
| root | descendants | direct | kind | where |
|---|---|---|---|---|
FeatureElement | 386 | 7 | interface | net/minecraft/world/flag |
ItemLike | 366 | 2 | interface | net/minecraft/world/level |
TabOrderedElement | 334 | 2 | interface | net/minecraft/client/gui/components |
GuiEventListener | 332 | 5 | interface | net/minecraft/client/gui/components/events |
Renderable | 269 | 6 | interface | net/minecraft/client/gui/components |
TypedInstance | 252 | 5 | interface | net/minecraft/core |
DebugValueSource | 248 | 3 | interface | net/minecraft/util/debug |
Packet | 240 | 229 | interface | net/minecraft/network/protocol |
SlotProvider | 231 | 2 | interface | net/minecraft/world/entity |
ContainerEventHandler | 224 | 3 | interface | net/minecraft/client/gui/components/events |
Nameable | 212 | 6 | interface | net/minecraft/world |
DataComponentGetter | 200 | 4 | interface | net/minecraft/core/component |
ItemOwner | 195 | 3 | interface | net/minecraft/world/entity |
UniquelyIdentifyable | 194 | 1 | interface | net/minecraft/world/level/entity |
EntityAccess | 193 | 1 | interface | net/minecraft/world/level/entity |
ScoreHolder | 193 | 1 | interface | net/minecraft/world/scores |
SyncedDataHolder | 193 | 1 | interface | net/minecraft/network/syncher |
Validatable | 174 | 22 | interface | net/minecraft/world/level/storage/loot |
LayoutElement | 173 | 4 | interface | net/minecraft/client/gui/layouts |
StringRepresentable | 165 | 164 | interface | net/minecraft/util |
NarrationSupplier | 146 | 3 | interface | net/minecraft/client/gui/narration |
Waypoint | 131 | 2 | interface | net/minecraft/world/waypoints |
Attackable | 126 | 2 | interface | net/minecraft/world/entity |
WaypointTransmitter | 125 | 1 | interface | net/minecraft/world/waypoints |
Leashable | 121 | 2 | interface | net/minecraft/world/entity |
ParticleProvider | 119 | 114 | interface | net/minecraft/client/particle |
Targeting | 116 | 2 | interface | net/minecraft/world/entity |
EquipmentUser | 115 | 1 | interface | net/minecraft/world/entity |
NarratableEntry | 103 | 2 | interface | net/minecraft/client/gui/narration |
RenderLayerParent | 97 | 1 | interface | net/minecraft/client/renderer/entity |
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
I · Anatomy
Verified against Minecraft 26.3 · Part I · The whole program at once: which threads exist, which loop each runs, and how the two halves talk.
Part I is the program the other twelve parts run inside. Java Minecraft is one codebase running as two programs — a server whose whole life is a tick loop, and a client whose life is a frame loop with ticks inside it — on four threads worth memorising: the Render thread, which is also the client’s game thread; the Server thread, which is the world; the Netty event loop, which carries the packets (Netty is the networking library both halves use); and one shared worker pool that chunk generation, the server’s lighting and section meshing all queue onto. A player recognises the split by its symptom, the game that keeps drawing while the world stands still, and the book returns to it wherever the two halves disagree, as when a broken block comes back for a moment. Read this part first for the plainest reason there is: nearly every lane in every sequence diagram after it is a class that runs on one of these threads, and the ten that are not a class are a thread, the process, the wire, the disk, the session service or the game’s own code.
The shape of the part
Two pages: what the program is, and where the book stops. The first is a trace and the second is a boundary, and the boundary is drawn second because a reader decides whether to go on once they can see the whole program.
flowchart LR
A["1 · Anatomy<br/>a trace: four threads, two loops, one wire"]
B["2 · What this book skips<br/>a boundary: where the book stops"]
A -- "the program first" --> B
The part’s two lectures, numbered to the watch order. The arrow is a dependency, not a call: the boundary means nothing until you can see the program it is drawn around.
Before you start
Nothing. This is the first part, and it assumes only that you have played the game.
Watch in this order
- Anatomy — from main to a running singleplayer world: the Render thread that is also the game thread, the Server thread that is the world, the Netty threads that run more than bytes, and the one CPU pool that chunk generation, the server’s lighting and section meshing all share.
- What this book skips — the boundary, drawn honestly on the treemap of the jar: save migration, Realms, telemetry, the profilers, the management server, the data generators, and where to start if you need one anyway. It is the second lecture and not the last because a boundary is worth drawing before you invest in thirteen parts, not after.
Where the part stops
Part I says which threads exist and hands the two loops and the wire on. What the Server thread does with a tick is Part III; what the frame loop does between ticks is Part X, and the frame itself Part XI; what crosses the wire between them is Part IX. This part owes you only enough of each to read a lane.
The part’s own packages are the two entry points, the client’s GameConfig
and SilentInitException, and the two loop classes, and it leaves none of
them unnamed: 0% of the part’s lines are named on no page in the book.
The boundary that matters here is the book’s rather than the part’s, and
drawing it is the second lecture’s whole job.
Reference this part uses
Diagram lanes — the abbreviations every sequence diagram uses, needed from the first figure of the first lecture onward. Threads — every thread, who makes it and what may run on it; Part I names the four to memorise and this is the rest. Naming drift — the 1.21-era names that have moved. The atlas — where everything is, and the treemap the second lecture reads.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Anatomy
Verified against Minecraft 26.3 · Part I · Clicking Singleplayer, picking a world, and standing in it a few seconds later.
A player clicks Singleplayer, picks a world from the list and waits. One
thread has been running since main; by the time the world appears there
are two that matter, and the second was created by the first, mid-frame,
while the first went on drawing. They are two programs sharing a JVM. The
server is the world — every chunk, entity and block, and the only thing
allowed to change them; the client is a window, a frame loop and a copy of
the world it is told about. Between them runs a Netty channel that never
touches a socket, and over it the client walks the same handshake, login,
configuration and play state machine it would walk against a server on the
other side of the planet. The packets are real. What leaks between the two
halves is not world state but a setting: pause is decided on the client,
by Minecraft.isPaused, and enforced on the server, by
IntegratedServer.tickServer running IntegratedServer.tickPaused instead
of the world. The decision has a third term that nobody sees in the menu:
the client only sets the flag while IntegratedServer.isPublished is false
— which is why a world published to LAN never pauses, however deep in the
options screen you are.
The cast
| class | what it decides | thread |
|---|---|---|
Minecraft | the client: the Window, the resource system, the renderers, input and Options — and, in three fields, whether we are in a world at all | Render |
MinecraftServer | the world and the loop that advances it. Abstract, with three concrete subclasses: IntegratedServer, DedicatedServer, and GameTestServer, the headless harness the gametest entry point launches | Server |
IntegratedServer | singleplayer’s server: the pause, LAN publishing, the player cap, the relaxed limits | Server |
BlockableEventLoop | the queue-and-thread pairing both loops are — Minecraft and MinecraftServer each extend ReentrantBlockableEventLoop | one per queue, and a thread may own more than one |
Connection | one channel, and which PacketListener is currently on it | Netty |
ServerConnectionListener | which channels the server listens on, including the in-memory one singleplayer uses | the binding thread (Render in singleplayer), into Netty |
PacketProcessor | which decoded packets are waiting to be handled on the thread that owns their state | filled from Netty, drained by the owner |
Util | the pools everything else is serialised onto: Util.backgroundExecutor, Util.ioPool, Util.nonCriticalIoPool | — |
Three of Minecraft’s nullable fields between them mean “we are in a
world”: Minecraft.level (a ClientLevel), Minecraft.player (a
LocalPlayer) and Minecraft.gameMode (a MultiPlayerGameMode). A fourth,
Minecraft.singleplayerServer, holds the IntegratedServer when one is
running, and is the client’s answer to “am I the host”.
From main to a world
sequenceDiagram
box transparent the client
participant Main as Main
participant MC as Minecraft
participant Conn as Connection
end
box transparent the server
participant IS as IntegratedServer
participant SCL as ServerConnection<br/>Listener
end
Main->>Main: tryDetectVersion, loadLibraries, bootStrap, validate
Note over Main: from here this thread is the Render thread
Main->>MC: the constructor: a backend, a Window, every reload listener
MC->>MC: the first ReloadInstance, the LoadingOverlay on screen
Main->>MC: run: pollEvents, then runTick, until running goes false
Note over Main,Conn: one loop so far. The next line makes the second.
MC->>IS: spin builds it on this thread, then starts the Server thread
par on the Server thread
IS->>IS: runServer: initServer loads the level
and meanwhile, on the Render thread
MC->>MC: doWorldLoad: draw a frame, drain the queue, until isReady
end
MC->>SCL: startMemoryChannel: a Netty local address, no socket
MC->>Conn: connectToLocalServer: the client's end of that channel
Conn->>Conn: handshake, login, configuration, play — the walk any client makes
The one-time start-up, and the line where one loop becomes two: in the split
box the Render thread goes on drawing while IntegratedServer.initServer loads
the level. Everything below it is the client dialling a server that happens to
be in the same JVM.
That is the book’s first sequence diagram, and its lanes are abbreviated the way every later one is: two or more letters of a class name, one meaning throughout. The key is diagram lanes.
Five main methods, and this trace starts in one of them.
client/main/Main is the client’s, server/Main the dedicated server’s,
data/Main the data generator’s, client/data/Main the generated client
assets’ — models, atlases, equipment assets, waypoint styles — and
gametest/Main the one that launches GameTestServer. (The tree holds a
sixth main, SnbtDatafixer, which converts files and starts nothing.)
Each parses its own command line — the client’s into a GameConfig the
Minecraft constructor is built from — and then reads its own settings
file: options.txt through Options on the client and server.properties
through DedicatedServerProperties on the dedicated server. The two generator entry
points are build-time programs, and
what this book skips
says how far that is true.
Bootstrap before anything exists. SharedConstants.tryDetectVersion
reads version.json as the first statement of every one of those main
methods, before the option parser exists; NativeLibrariesBootstrap.loadLibraries unpacks
the natives, CrashReport.preload warms the reporter, and
Bootstrap.bootStrap builds and freezes the static registries — blocks,
items, entity types, the things that cannot be data-driven because the data
loader itself needs them
(identifiers and registries
is what the freeze proves). ClientBootstrap does the client-only equivalents
between that and Bootstrap.validate, which checks the result.
DataFixers.optimize is kicked off concurrently before the registries are
built and joined much later.
The GPU backend is chosen in the constructor.
RenderSystem.initBackendSystem runs first and returns SDL’s clock, which
Minecraft installs through Util.setTimeSource — so Util’s clock, on the
client and on a singleplayer server beside it, comes from the windowing
library. Then a GpuBackend is chosen by trying candidates in an order
Options sets, unless a launch argument names one, until one of them
loads its library and makes a GpuDevice — there are two, GlBackend and
VulkanBackend
(the window)
— and only then is the one Window made, through the backend that
succeeded. From then on the renderer only ever sees the GpuDevice
abstraction in com/mojang/renderpearl.
Construction registers, it does not load. The constructor creates each
manager and registers it on the ReloadableResourceManager; the loading is
one ReloadInstance whose prepare phases run on Util.backgroundExecutor
and whose apply phases run on the Render thread, with the LoadingOverlay
on screen. Pressing F3+T re-runs exactly that path — see
the resource system.
Opening a world spins a server. Minecraft.doWorldLoad calls
MinecraftServer.spin, which constructs the IntegratedServer on the
caller’s thread before starting the new one
(starting a server
has that order in full); the new thread’s body is MinecraftServer.runServer,
which calls IntegratedServer.initServer and enters the loop. Meanwhile
Minecraft.doWorldLoad keeps the Render thread drawing frames and draining its
own queue until MinecraftServer.isReady, waiting out each sixtieth of a
second in BlockableEventLoop.managedBlock: a thread that blocks on this half
of the game keeps running that half’s queue while it waits, which is the
reason the wait cannot deadlock.
The client connects like any other client.
ServerConnectionListener.startMemoryChannel binds a Netty local address and
Connection.connectToLocalServer connects to it; the client then walks
handshake, login, configuration and play as it would against a remote server,
with ClientHandshakePacketListenerImpl as its login listener. The server
gives the local channel a handshake listener of its own,
MemoryServerHandshakePacketListenerImpl, and skips the encryption request
and compression it would send a remote client. Almost nothing in the play path
knows it is singleplayer: the exceptions are the handful of places that ask
Connection.isMemoryConnection or
ServerCommonPacketListenerImpl.isSingleplayerOwner directly, among them
ClientPacketListener.handleUpdateTags, which skips
applying the tags it was sent because the server’s registries are already
the client’s. The
connection
has what the local channel does and does not install, and protocol
phases is the walk in full.
Two loops, and a wire between them
The client’s loop is a frame loop with ticks inside it; the server’s is a tick loop with no frames at all. They are not the same shape, and no page later in this book is readable until that difference is fixed in mind.
flowchart TD
subgraph Client["the Render thread"]
CR["Minecraft.run: RenderSystem.pollEvents,<br/>then Minecraft.runTick, once per frame"]:::client
subgraph Frame["Minecraft.runTick"]
CD["the DeltaTracker counts the whole ticks owed"]:::client --> CP["PacketProcessor.processQueuedPackets"]:::client
CP --> CQ["BlockableEventLoop.runAllTasks"]:::client
CQ --> CT["Minecraft.tick, 0 to 10 times"]:::client
CT --> CF["Minecraft.renderFrame, on the partial tick"]:::client
end
CR --> CD
CF --> CR
end
subgraph Server["the Server thread"]
SR["MinecraftServer.runServer: the deadline is set first"]:::server
subgraph STick["one server tick"]
SP["PacketProcessor.processQueuedPackets"]:::server --> SS["MinecraftServer.tickServer: every ServerLevel, then the connections"]:::server
end
SW["MinecraftServer.waitUntilNextTick:<br/>run queued tasks, then park"]:::server
SR --> SP
SS --> SW
SW --> SR
end
The two loops side by side: the Render thread’s ring is a frame with a tick
inside it, and the Server thread’s ring is the tick with no frame anywhere in
it. The inner box in each is the call that holds the tick — Minecraft.runTick
on one side, MinecraftServer.processPacketsAndTick on the other.
The frame loop. Minecraft.run polls SDL events and calls
Minecraft.runTick once per frame, as fast as vsync or the frame-rate
limit allow. Inside each frame a DeltaTracker.Timer running at twenty ticks
a second says how many whole game ticks have elapsed since the last frame —
usually zero or one, at most ten are run — and Minecraft.tick is called that
many times, with BlockableEventLoop.runAllTasks draining the thread’s own
queue just before them. The fractional remainder is the partial tick the
renderers read, to interpolate with, from the DeltaTracker that
Minecraft.renderFrame hands them. So the client
has a 20 Hz tick, but it is a sub-step of
the frame loop rather than a loop of its own; the client
loop is the
arithmetic in detail.
The tick loop. MinecraftServer.runServer re-reads this tick’s length
every iteration from TickRateManager.nanosecondsPerTick — 50 ms by default,
whatever /tick rate says otherwise, and zero under /tick sprint — and calls
MinecraftServer.processPacketsAndTick, which drains the PacketProcessor
and then runs MinecraftServer.tickServer. Afterwards
MinecraftServer.waitUntilNextTick spends the slack running queued tasks and
then parks until the next tick is due. There is no frame and no partial tick
here at all. The “Can’t keep up!” warning is MinecraftServer.runServer’s
own, decided
before either call from how far behind the deadline already is; the server
tick
owns what is inside them — the tick budget,
the deferrable work and the flush bracket around outbound packets.
Both are event loops first and game loops second. Minecraft and
MinecraftServer both extend ReentrantBlockableEventLoop — the same base
class, not an analogy — so each is an Executor whose queue drains on its own
thread, and another thread that wants to touch that half’s state submits a task, or leaves its work in one of the queues beside the loop’s own that the owning thread drains — a packet in the PacketProcessor (below), a dedicated server’s console line in a list DedicatedServer.tickConnection drains, a level’s chunk work in its ServerChunkCache.MainThreadExecutor (below). BlockableEventLoop.managedBlock is the blocking form, and
the reason the owning thread can wait for a future without deadlocking: it
keeps draining its own queue while it waits.
A packet is decoded on one thread and handled on another. A packet
arrives on a Netty IO thread and Connection.channelRead0 hands it to the
current PacketListener; a handler that touches game state calls
PacketUtils.ensureRunningOnSameThread, which, when it is off-thread, queues
the packet on the owning side’s PacketProcessor instead of running it —
the connection
is that crossing in both directions. What matters here is when the queue is
drained, because the two loops do not agree: first thing in
MinecraftServer.processPacketsAndTick, but on the client early in
Minecraft.runTick, after the delta tracker advances and before the ticks —
so a client at 200 frames a second takes the server’s packets ten times more
often than it ticks.
Four threads worth memorising
| thread | made by | runs | notes |
|---|---|---|---|
| Render thread | the JVM main thread, renamed in client/main/Main | Minecraft.run | Also the client’s game thread: Minecraft.gameThread is this thread. Priority 10 on machines with more than four processors. |
| Server thread | MinecraftServer.spin | MinecraftServer.runServer | One per server, so singleplayer has exactly one. Priority 8, on the same more-than-four-processors condition. |
| Netty IO | EventLoopGroupHolder | the Connection pipeline | Named Netty NIO IO #n — Epoll or Kqueue when native transport is on, Netty Local IO #n for the in-process singleplayer channel. Decode, decrypt, decompress — and, unlike the play phase, the handshake and login handlers, which never call PacketUtils.ensureRunningOnSameThread; the first handler that hops is in configuration. The login state machine is still advanced from the Server thread, because ServerLoginPacketListenerImpl is a TickablePacketListener and MinecraftServer.tickConnection ticks it. |
| Worker-Main-n | Util.backgroundExecutor | a ForkJoinPool sized to the JDK’s available-processor count minus one | Util.maxAllowedExecutorThreads clamps it, and Util.getMaxThreads reads a max.bg.threads system property that overrides the ceiling. The shared CPU pool: chunk generation and lighting (ChunkMap through ChunkTaskDispatcher), section meshing (SectionRenderDispatcher), resource-reload prepare phases, chunk serialisation. |
That is the set worth memorising, not the set that exists. The IO workers, the sound engine’s event loop, the dedicated server’s watchdog, console, RCON, query and management threads, the timer hack thread and the situational ones — authentication, chat filtering, server pinging, telemetry, world upgrades, the shutdown hooks — are all in Threads, with who makes each and what it is allowed to touch.
There is no fifth thread hiding on the client. The Render thread is the
game thread: Minecraft.gameThread and the thread RenderSystem guards with
RenderSystem.assertOnRenderThread are the same one, and there is no
initGameThread and no isOnGameThread anywhere in the tree — only
RenderSystem.isOnRenderThread. A slow client tick costs frames directly.
What that thread does with a world is animate and predict one (Minecraft.tick
calls ClientLevel.tickEntities, and block entities tick too), but nothing it
concludes is authoritative, and the server’s packets overwrite whatever the
prediction got wrong — which is a claim about the world, and the client’s
own player is the exception the book spends Part VIII and
prediction and acknowledgement
on.
Everything else that matters is serialised onto a pool rather than given a
thread. ConsecutiveExecutor and PriorityConsecutiveExecutor, in
util/thread, are the mechanism: a queue that promises to run its tasks one at a time on a pool
that otherwise runs many, which is how “worldgen” and “light” stay ordered on
the worker pool, and how the IOWorker stays ordered on Util.ioPool —
which is its own pool of IO-Worker-n threads, not one of the four. And ServerChunkCache.MainThreadExecutor is a further event
loop layered on the server thread, which is why a tick that waits on a chunk
does not deadlock the chunk that needs the tick.
What singleplayer shares by direct call
Every block, entity and inventory change a player makes crosses as a packet,
even in one process. But the two halves share a JVM, and a handful of things
do cross by direct call — settings, debug state and the options screen’s own
updates — and a setting can reach the world before the call returns. The server reads Minecraft.isPaused every tick and the client’s
render and simulation distances every tick it runs the world;
WorldOptionsScreen.applyChanges calls IntegratedServer.publishServer and
its siblings straight from the Render thread, and among them
IntegratedServer.setPersonalGameType sets the host’s own game mode through
ServerPlayer.setGameMode on the Render thread, not the Server thread; and
IntegratedServer.latestTicksGizmos is a volatile list the server thread
writes and the client reads. Treat “everything crosses as a packet” as a rule
about play, not about the process.
Singleplayer differs in more than pausing, too. Beyond the pause and the
distances following Options, IntegratedServer caps the player list at
eight, owns LAN publishing and the LanServerPinger, drops the chat and
command spam thresholds to zero where a dedicated server defaults to ten,
takes native transport from the client’s own option rather than a server
property, and answers the operator-permission questions differently.
A crash in singleplayer surfaces on the wrong half
Neither loop throws. Both catch everything and wrap it in a CrashReport,
and a background thread that dies parks its report for a loop to pick up
(how a server dies
owns that relay, and
the client loop
the client’s own three exits). The asymmetry is Part I’s, because it follows
from two programs sharing a JVM: only a loop constructed to propagate
crashes rethrows a parked report, and IntegratedServer is not one. So a
worker that dies doing the server’s work in singleplayer takes down the
client, and the crash report names a thread the player was not watching.
Questions players ask
Does a dedicated server pause? Yes — an empty server stops ticking on its own (the server tick has the counter and what still runs). Pausing is not a singleplayer concept; only the client-decides-it half is.
Is twenty ticks a second a constant? No, it is a server field. The
TickRateManager.nanosecondsPerTick the loop re-reads lives on a
ServerTickRateManager, the server’s subclass of it, which adds the sprint
/tick manipulates and sends clients the rate and the freeze; the client
mirrors those two in ClientLevel, so the DeltaTracker slows and freezes
with the server.
Does a busy server skip work? Less than you would think. The tick
budget is one predicate, MinecraftServer.haveTime, travelling from
MinecraftServer.tickServer down through every level, and what it actually
gates is a short list that does not include loading or generating a chunk. The server
tick has that list,
and the sprint’s inverted effect on it.
For a 1.21-era reader. The client’s clock is
DeltaTracker, which was Timer, and the partial tick is aDeltaTracker.Timeryou ask rather than a float you are handed — it appears in the frame loop above and in every frame’s extract in Part XI. GLFW is gone; SDL makes the window, delivers its events and supplies the time source. The rest of the drift a 1.21 reader will trip on is naming drift.
Where to look
client/main/Main · Minecraft · server/Main · MinecraftServer ·
IntegratedServer · BlockableEventLoop · Util (the executors) ·
EventLoopGroupHolder · ServerConnectionListener · Connection ·
PacketProcessor · PacketUtils
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
What this book skips
Verified against Minecraft 26.3 · Part I · A reader opens the atlas, sees fourteen packages hatched, and asks what is in them and why no part counts them.
Open the atlas and part of the jar is drawn hatched. That hatching is this
page. Java Minecraft is 7,301 source files and 741,069 lines, and the parts do
not reach all of it. Some of what is left out is excluded on purpose by the
newest-version-only rule —
save migration is version-difference code, and a book that documents only
the current version has nothing to say about it. Some is out of scope because it is a client for a service this
book cannot read. And one hatched box is not skipped code so much as skipped
ground: net/minecraft/data is the program that writes vanilla’s own
content as a data pack, it ships in the dedicated server jar — all 174 files of it — and the running game compiles against it and calls into it.
Blocks names TreeFeatures keys while it constructs mushroom blocks;
MinecraftServer reaches for a MiscOverworldFeatures key for the bonus
chest; the F3 screen’s biome-builder line runs through
NoiseRouterData.peaksAndValleys into TerrainProvider. A boundary drawn
honestly has to show where it leaks, and that is the largest leak in it.
The sizes, and which jar ships them
Each entry below says what the thing is, roughly how big it is, whether the
dedicated server ships it, one fact worth knowing, and where to start
reading. The treemap deliberately does not hatch net/minecraft/gametest:
Part XIII covers it, so it is a gap that closed rather than a skip. It cannot
hatch player reporting either, which is why the table has fifteen top-level
rows against the map’s fourteen hatched packages: the map’s smallest box is a
package four levels deep, and net/minecraft/client/multiplayer/chat/report
is six, sitting inside a package Parts IX and X between them own. The counts in the tables are files unless a cell says classes, so a package-info.java counts as one there and wherever the prose quotes the tables; a cell or a sentence that counts a
package’s working classes leaves it out.
| package | files | lines | side |
|---|---|---|---|
net/minecraft/util/datafix | 411 | 27,048 | both |
net/minecraft/util/filefix | 57 | 3,553 | both |
net/minecraft/client/telemetry | 18 | 1,221 | client |
net/minecraft/util/profiling | 70 | 4,265 | both |
net/minecraft/server/jsonrpc | 65 | 4,118 | dedicated server |
net/minecraft/server/rcon | 9 | 837 | dedicated server |
com/mojang/realmsclient | 137 | 14,701 | client |
net/minecraft/realms | 4 | 203 | client |
net/minecraft/stats | 10 | 874 | both |
net/minecraft/gizmos | 15 | 569 | both |
net/minecraft/references | 5 | 1,483 | both |
net/minecraft/data | 174 | 16,849 | both — see below |
└ net/minecraft/data/worldgen | 64 | 6,016 | both — see below |
net/minecraft/client/data | 28 | 6,189 | client |
com/mojang/blaze3d/audio | 12 | 1,013 | client |
net/minecraft/client/multiplayer/chat/report | 12 | 952 | client |
The oracle for “server or client” throughout is the list of classes the
dedicated server jar ships, which lives beside the decompile. It answers
exactly one question — does the dedicated server have this class — so it
can prove “client-only” and it can prove “both jars”, and it cannot prove
“dedicated server only”. Two rows are labelled that way on the strength of a
different check: nothing under net/minecraft/client or com/mojang/blaze3d
references them. rcon is reached from DedicatedServer alone; jsonrpc
from six files, and two of them — BuiltInRegistries and Registries — are
classes the client loads at bootstrap, so the package’s types are on the
client and the server it configures is not.
Save migration, and the fixer that moves files
net/minecraft/util/datafix — the largest thing on this page and the
most explicitly out of scope. DataFixers is one static class whose whole
body is the migration history of the game written out longhand: just over
three hundred schema registrations and four hundred-odd fixes, from schema 99 up
to the current world version. The rewriting machinery itself is Mojang’s
external DataFixerUpper library; what lives here is the vanilla catalogue —
util/datafix/schemas describing the shape of the data at each version,
and util/datafix/fixes doing the individual rewrites.
A version number becomes a chain of fixes through DataFixTypes, an enum
of about thirty type references (level, chunk, player, entity chunk, POI
chunk, options, stats, advancements, and a long tail of saved-data kinds).
DataFixTypes.updateToCurrentVersion takes the data version as an
argument — every one of its fourteen callers reads the version itself — and
asks the fixer to compose every rule from there to now.
DataFixTypes.wrapCodec is the one that reads the version out of the tag:
it wraps an ordinary codec so decoding pulls the data version, runs the
chain, and encoding stamps the current version back in. It is the rarer
door — two callers, PlayerAdvancements and DebugScreenEntryList — while
player data takes the first one and reads the version itself, and
chunk storage reads it too and takes a third,
DataFixTypes.update. The rules are
pre-compiled on a dedicated bootstrap thread, and that thread is built with
some care to stay out of the way: one thread, daemon, at minimum priority, with a
single caller in the client’s entry point, optimising exactly one type (the
level-summary schema, so the world list opens fast). The dedicated server
never asks for it at all.
net/minecraft/util/filefix does what the other cannot. A data fixer
rewrites the contents of a tag after it has been read, so it can never
move, rename, split or delete a file. FileFixerUpper operates on the
world directory: its operations are moves, regex moves, group moves,
deletions, content modifications and one composite that scopes a nested list
of operations to matching folders, and the concrete fixes do things like
relocate dimension storage, gather player storage into one folder and pull data out of
level.dat into saved data
(level data and rules).
It stays safe by working somewhere else: the whole upgrade runs against a custom copy-on-write file system over the world folder, whose writes land in a scratch directory, and the result is swapped in at the end. Exactly how safe depends on the filesystem underneath. Where hard links are available it uses them. Where they are not, it writes one file, upgrade_in_progress.json, recording the moves, and an upgrade cut short by a crash resumes from it the next time the world is opened, while one that fails on an error tries to move its files back. And where atomic move is unavailable it stops before the swap rather than risk a half-moved world.
The client does not grey out a world that needs the upgrade — it relabels
the button. LevelSummary.primaryActionMessage turns Play into Upgrade and
Play while leaving it active; what is disabled is Edit and Recreate, which
would otherwise touch a directory the fixer is about to rearrange.
Telemetry writes to disk before it writes to the network
net/minecraft/client/telemetry, client-only. Exactly seven event
types: world loaded, world unloaded, graphics capabilities (which now
carries the backend name and the reason a backend failed — see
Blaze3D), and four opt-in ones covering
performance metrics, world load times, advancements and game load times.
TelemetryProperty is the vocabulary; each property carries both an
internal name and an export key.
Opting out is two-tier, and the game controls only one of the tiers.
Minecraft.allowsTelemetry reads an account-level flag the game only
reports; the in-game control only chooses whether the four opt-in events
are sent, and is only offered when the account carries the flag that allows
it. Everything sent is also written locally as a JSON event log with a
seven-day expiry — and the send waits on the log opening, so a log that
cannot be opened suppresses the send. A player can read their own outgoing
telemetry, though not in the game: the telemetry screen renders the
catalogue of event types and their properties, and a button next to it
opens the log directory in the platform’s file manager.
Start at ClientTelemetryManager, TelemetryEventType.
Four profiling systems, and Tracy is the surprise
net/minecraft/util/profiling holds four profiling systems. Two sit in
the package itself — the tick profiler and the Tracy bridge — and two in
subpackages of it, jfr and metrics.
The tick profiler is the familiar one: Profiler is a thread-local
holder of a ProfilerFiller, ActiveProfiler records the push/pop tree of
named sections that pages in this book quote, and /perf and the client’s
F3 profiler chart drive it.
Tracy is the surprise, and it is one class bridging out to Mojang’s
Tracy binding. TracyZoneFiller implements the same interface, and
Profiler.get falls back to the Tracy filler rather than the inactive one
when Tracy is available — and Profiler.decorateFiller combines the two,
so an attached Tracy build and a running /perf both see every section. With a Tracy build attached, every profiler section in the game
streams out with no command run. Tracy reaches outside this package too,
into Blaze3D’s frame capture, into the GPU abstraction’s TracyGpuProfiler
and into the executor wrappers.
JFR (util/profiling/jfr) registers ten custom flight-recorder events
under a Minecraft category — chunk generation, region reads and writes,
packets sent and received, network summaries, server tick time, client FPS,
structure generation, world load. Packet events are emitted straight from
the packet codecs, so a recording gives a per-packet-type byte breakdown
that this book’s packet reference cannot.
Start with –jfrProfile or /jfr start.
Metrics (util/profiling/metrics) is /perf: sampling by nine
MetricCategory values — pathfinding, event loops, consecutive executors,
the tick loop, JVM, chunk rendering, chunk-rendering dispatch, CPU and GPU —
written out as CSVs that PerfCommand zips.
The management server is not RCON
net/minecraft/server/jsonrpc, dedicated server only, and genuinely
new. It is JSON-RPC 2.0 over a WebSocket, served by its own Netty bootstrap
with an HTTP codec, an authentication handler, the WebSocket handshake and
optional TLS. It is disabled by default; when enabled, TLS is on unless
explicitly turned off, and the server refuses to start without a
forty-character alphanumeric secret, generating one if absent.
What it exposes is the administrator’s surface, not the game’s: allow-list, bans and IP bans, players and kicks, operators, game rules, server status, save and stop, system messages, and a family of live server settings — including the idle-pause window, whose behaviour belongs to the server tick, because a dedicated server pauses too. Implementations sit behind service interfaces so the wire layer never touches the server object directly, and an executor service marshals calls onto the server thread.
The description of the API cannot drift from the handlers, because it is derived from them: every method is registered with a description and typed parameter and response schemas, and a discovery method returns an OpenRPC 1.3.2 document built by walking the two method registries and filtering on a per-method discoverable flag. There is also an outgoing direction for server-initiated notifications. The audience is panel and hosting operators.
Start at JsonRpc, ManagementServer.
RCON, query, and the pre-1.7 ping that removes itself
net/minecraft/server/rcon is seven classes of pre-Netty blocking
socket code on its own threads. RconThread accepts the connections and
RconClient speaks Valve’s Source RCON framing; commands execute as a RconConsoleSource, a command source that
accumulates output into a string rather than a chat feed
(Brigadier and commands).
QueryThreadGs4 speaks the GameSpy4 UDP query protocol with a
challenge-token handshake and a five-second cache on its full-status reply.
The pre-1.7 ping is not in that package. LegacyQueryHandler sits in
the server’s network package and is installed into the Netty pipeline
before the length-prefix splitter and the packet codec, right after the
read timeout (the connection). It peeks
at the first byte; if it is the legacy ping marker it answers in the old
format and closes, and otherwise it resets the reader index, removes
itself from the pipeline, and re-fires the bytes downstream. It costs one
byte comparison per connection and then vanishes. The same encoding is used
client-side so the server list can still ping ancient servers.
Realms is a client for a server nobody here can read
com/mojang/realmsclient, client-only, 137 files and 14,701 lines —
more lines than the whole packet catalogue in network/protocol. Roughly
sixty per cent is screens and the records behind them — subscriptions, world
slots, templates, invites, backups, minigames, upload and download — and the
rest is a task framework, the world-upload pipeline and the HTTP layer, a
list of REST paths with a small request wrapper. net/minecraft/realms is
three classes and a package-info.java vanilla keeps for Realms, RealmsScreen
among them, the base the Realms screens extend; beyond it the Realms UI extends
ordinary vanilla screens and widgets.
Out of scope because it is a service client: its behaviour is defined by a server this book cannot read. One fact anyway. The environment is chosen from an environment variable falling back to a system property, defaulting to production, in a static final field of the release client — and there are three environments, not two, because the third points at localhost.
Statistics, the scoreboard, and the recipe book
net/minecraft/stats is nine classes covering two concerns, plus a link
into a third package that is the reason it is worth a paragraph.
Statistics: Stats declares eight registry-backed stat types — mined,
crafted, used, broken, picked up, dropped, killed, killed by — plus a
custom type holding the seventy-odd hand-declared counters (play time,
distances by every mode of travel, damage dealt and blocked), each bound to
a formatter that affects display only. A stat type is a lazily-populated
map over a registry, so stat objects are interned; ServerStatsCounter
adds the per-player file and a dirty set, and only dirty stats are sent.
Statistics are one of two parts of the save that go through the data
fixer as JSON rather than NBT — the other is advancement progress
(advancements), and they are the only two.
The scoreboard link is why the package is worth a paragraph — and note
that the class doing the linking is not in it: ObjectiveCriteria lives in
net/minecraft/world/scores/criteria, which is why a scoreboard objective
can name a statistic and why this package is reachable from a command at all
(scores, teams and stored data
is how the name is parsed).
The recipe book is the second concern, and it is in this package for
historical reasons rather than architectural ones — RecipeBook,
RecipeBookSettings and ServerRecipeBook are not skipped, they belong to
recipes, with advancements
reaching in from the other side. The address is the only thing surprising
about them.
Two packages nobody will recognise
net/minecraft/gizmos is a debug-drawing API, in the game-engine
sense of the word: the immediate-mode “draw me a box in the world for one
frame” facility most engines have and Minecraft did not. Every one of the
debug renderers — chunk borders, hitboxes, pathfinding, brains, points of
interest, raids, light sections — is now written against it, and it is not
client-only, which is the surprise. It is taught where its output is, in Part
X, by debugging the running
game;
the address is what belongs here, because a reader hunting for the debug
renderers’ drawing code will look under client/renderer/debug and find
nothing that draws.
net/minecraft/references is not “references” in the data-fixer sense.
It is a set of id-constant tables, and the split is not the one the
package names suggest: BlockIds holds the keys for blocks with no item
form (water, lava, wall torches, piston heads, wall signs), ItemIds the
items with no block, and BlockItemIds — seven times BlockIds and not
quite twice ItemIds — the pairs. Look for stone in BlockIds and it is not there. They exist to break
a class-initialisation cycle: exactly twelve files outside the package
name it, and four of them are the ones that need to name a block or item
before the block and item classes are loaded — Blocks and Items
themselves, and GrassBlock and MyceliumBlock, which name another block
during that initialisation — while PotDecorations looks its brick up by key.
The other seven are data generators that work in keys: four tag providers, a
tag appender and two feature bootstraps, AquaticFeatures and
NetherFeatures. A resource key is
a registry plus an identifier, so it can be
built with nothing loaded. Practically, it is the canonical machine-readable
list of block and item ids, and a better starting point than the block and
item holder classes if that is what you want — but not the id list: five
sibling tables for entity types, block-entity types, potions, fluids and
atlases live outside the package, in the trees they belong to.
The data generators, and why “data-driven” is both true and misleading
Most of net/minecraft/data is a build-time program: a second entry
point with its own options, a generator that groups providers into packs,
and a hash cache that skips unchanged files. net/minecraft/client/data is
its client half, generating block and item models, the atlas definitions,
equipment assets and waypoint styles.
The significance is a genuine paradox worth stating plainly. Vanilla’s own
content is a data pack. net/minecraft/data/worldgen is most of the
vanilla worldgen data pack written as Java — the biome feature lists, the
material rules, the carvers, the jigsaw pools, the structures and structure
sets, the processor lists — and the loot, recipe,
tag and advancement packages do the same for their domains, all serialised
through the same codecs the game uses to read a pack.
So “Minecraft is data-driven” is true: the running game only ever sees JSON parsed by codecs, with no vanilla-specific path (the resource system). And “you cannot change it without a data pack” is nearly true — which is the more useful statement, because the exceptions are load-bearing and a reader who believes the absolute version will misread three other pages.
The package is not build-time only, and the dedicated server ships all 174 files of it. Three kinds of exception:
- Plain id tables.
AtlasIdsis read at runtime by the model manager, the atlas manager, the map, sky, painting and particle renderers, and by a chat component. Nothing build-time about it. - The bootstrap interface itself.
BootstrapContext— innet/minecraft/data/worldgen— is what every vanilla data-pack registry’s bootstrap is written against, from damage types and enchantments to chat types, dialogs and world clocks. It is the most-imported type in the package by a wide margin. - Constants and math the running game calls.
Blocksitself namesTreeFeatureskeys while constructing mushroom and fungus blocks, and aCaveFeatureskey and aVegetationFeatureskey for the moss and pale moss blocks;MinecraftServerreaches for aMiscOverworldFeatureskey for the bonus chest; a jigsaw block entity defaults to aPoolskey;NoiseRouterDataandNoiseGeneratorSettings, both shipped worldgen classes, are compiled againstTerrainProviderand the material rules innet/minecraft/data/worldgen/material; and the F3 screen’s biome-builder line callsNoiseRouterData.peaksAndValleys, one line that delegates straight intoTerrainProvider(density functions).
Vanilla’s density functions and noise settings still reach the running game
as JSON. NoiseRouterData.bootstrap and NoiseGeneratorSettings.bootstrap
are collected by VanillaRegistries, which the data-generator entry point
runs and which Commands.validate borrows for its ambiguity check; the game
itself reads the generated files out of the built-in pack. Editing
TerrainProvider changes terrain by changing what that generator writes.
So net/minecraft/data holds a build-time program and a handful of tables
and functions the shipped game compiles against and executes. The generator
half is inert at runtime but for one static helper StructureTemplateManager
borrows from NbtToSnbt, and it is the half worth reading — it is
the fastest way to understand what a vanilla biome or structure declares,
because it is typed and cross-referenced where the JSON is not, a point
biomes and
structure placement both depend on. And the report
providers are how you get machine-readable dumps of the registries and the
packets, two of the tables this book’s own
reference layer covers.
The audio backend lives in Blaze3D, and is not skipped
com/mojang/blaze3d/audio is hatched for its address, like
net/minecraft/gizmos above: the book teaches what it does and the atlas
still counts it outside every part, because it is filed where nobody looks
for it. It wraps OpenAL, and
it sits inside Blaze3D, beside the window and RenderSystem, rather than in the
client’s sound package where the engine, the manager, the channel bookkeeping
and the Ogg decoding live. That is the boundary fact: Blaze3D is the platform
layer for both devices, not only the graphics one, and a reader looking for
the sound code under client/sounds will not find the half that talks to the
driver. Everything the package does — the device and context, the channel
limits (which are counters and not pools, as that page’s own heading insists),
binaural rendering through Options.directionalAudio, hot-plugging a headset
mid-game — is taught, in Part X, by the sound
engine.
Player reporting
net/minecraft/client/multiplayer/chat/report, client-only.
ReportingContext holds the sender, the environment (which server or
realm), a log of the last thousand-odd received messages, and at most one
draft report. There are three report kinds — chat, skin, name — and an
eleven-value reason enum.
The piece worth naming is the context builder: a chat report does not send just the offending line, it walks the log backwards to assemble surrounding signed context, which is what makes the report verifiable at the other end. The report machinery is the consumer of the chat-signing system that chat and signing documents. Neither the transport nor the policy is in the game — both come from the account service library.
Gaps, and the ruling on each
The hatched boxes above are the boundary as drawn. Beside them sits a
shorter list of things that were never excluded on principle — they were
simply not written when the book reached them, and each has since been
ruled on. Ten are now taught, either as a page of their own
(net/minecraft/gametest, com/mojang/blaze3d/platform, the post-effect
chains, the scoreboard and command storage) or as a section of one (the
debug cluster across the HUD and debugging pages, item models on models and
atlases, the packrat parser on Brigadier and on codecs, the animation
framework on entity rendering, the pack classes on the resource system, and
net/minecraft/client/resources, which turned out to be five systems with
five owners rather than one gap). Naming where each landed is the landing
pages’ job, not this one’s; what belongs here is the other ruling.
Declined, with the reason. A decline is a promise that a reader will not miss it, not a shrug.
| what | size | why | what carries it instead |
|---|---|---|---|
com/mojang/renderpearl/backend/vulkan | 33 files, 7,387 lines | a faithful second implementation of an interface already documented, and the abstraction is the lecture | Blaze3D |
net/minecraft/client/data | 28 files, 6,189 lines | build-time model, atlas, equipment and waypoint generators, the same category as the generator half of net/minecraft/data, but big enough that a reader trips over it | named here and in the list of main methods on anatomy |
| the catalogues | ~140 entity models, ~80 particles, 102 render states, 50 render layers, 16 animation definitions, 56 of 58 worldgen features, 57 tree kits, the entity sub-predicates | each is one shape repeated, and the shape is on the page that owns the framework | the reference layer |
client/quickplay, client/profiling, client/renderer/gizmos | one or two classes each | no mechanism a lecture needs | — |
net/minecraft/data/worldgen as content | 64 files, 6,016 lines | declined as content: it is the datagen bootstrap that emits vanilla’s JSON | the runtime exceptions named above, which are not a decline |
net/minecraft/client/animation’s keyframe definitions | 16 of its 21 classes | pure data in Java clothing — and lines is the wrong unit for it: 266 lines and 665 KB, one file’s longest line thirty thousand characters, because the decompiler renders each animation as one builder chain | entity rendering has the framework |
Two things inside com/mojang/renderpearl/backend/vulkan are named before
the decline rather than after it: DestructionQueue, the deferred-free
discipline OpenGL needs no equivalent of, which is the clearest illustration
of what the device seam hides, and vulkan/checkpoints, vendor breadcrumb
extensions for GPU crash reports. The shader compiler is not in the declined
tree. GlslCompiler and SPIRVModule, the shaderc and spirv-cross pair, sit
in front of both backends in renderpearl/frontend/shaders, because
Minecraft still authors GLSL and compiles it to SPIR-V once — Vulkan takes
the SPIR-V as it is and OpenGL cross-compiles it back to GLSL — which is the
whole reason one shader source can feed two backends. The interiors of
com/mojang/renderpearl/backend/opengl are declined on the same grounds.
Named, and not yet written. These are real systems with real lectures
in them, and no ruling above covers them: the carver tunnel walk; the dragon
fight (EnderDragonFight); client/multiplayer‘s joining-a-server tail; and
two corners of the pack system that
the resource system names without teaching — the
server-resource-pack prompt and download flow in client/resources/server,
and the linkfs synthetic file system that presents the launcher’s hash-named asset files as the one tree their index describes. Beside them sit the maps’ saved data and the wandering trader’s timer, which the level data table names and no page teaches, four Part V mechanisms its landing page does not list: rails, the shelf, the beehive and fire spread; local difficulty (DifficultyInstance), which only that table’s page explains; and four things a player carries that Part VIII lists and does not teach — the attack’s visual effects, the damage statistics, the warden-spawn tracker and the spectator camera behind /spectate. They are
named here so that a reader who wants one knows the book knows it is
missing, and knows where to start.
Where to look
If you need one of these the entry points are named in each section; if you
want a list rather than a system, start at net/minecraft/references for
ids and the report providers in net/minecraft/data for everything else,
and the atlas for the shape of the whole jar.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
II · Foundations
Verified against Minecraft 26.3 · Part II · The machinery every later part assumes: how anything becomes data, gets a name and a number, is loaded, and reaches into code.
Part II is the vocabulary the other twelve parts speak without pausing to
define it. Nothing here is a thing a player does; everything here is what
happens underneath the things a player does — and a player still recognises
it by its symptoms: the square brackets after an item name in a /give, the
#minecraft:logs in a recipe file, the type line at the top of a recipe
file. Each of those is one of these seven pages
surfacing. The claim the part makes is that they are not seven mechanisms
but three: a codec describes a value, a registry gives it a name and a number,
a pack stack decides which copy of the file wins, and everything above is
those three used again — which is why a world made almost entirely of JSON
loads in seconds and why a data pack can recombine the game without adding a
single behaviour to it.
The shape of the part
Part II is not a stack but a fan. Codecs and registries come before everything else here, and the five pages below them lean on those two far more than on each other. The last page is the pattern the rest exists to make possible.
flowchart TD
C["1 · Codecs, NBT and JSON"]
R["2 · Identifiers and registries"]
S["3 · The resource system"]
T["4 · Tags"]
D["5 · Data components"]
X["6 · Text components"]
P["7 · The data-driven type pattern"]
C -- "decodes every pack-written element" --> R
C -- "a persisted component has one" --> D
C -- "text is a value like any other" --> X
R -- "/reload rebuilds the reloadable ones" --> S
R -- "the tag table is swapped after the freeze" --> T
R -- "an entry carries its starting components" --> D
R -- "the registry of kinds is built in" --> P
S -- "tags are read from the same stack" --> T
T -- "any set field takes a tag" --> P
D -- "a component type is a key, not a kind" --> P
The part as a fan, numbered to the watch order. An arrow points from the machinery to the page that takes it for granted: codecs at the top is what nothing else here is built without, registries hang directly off it, and the pattern at the foot, which no arrow leaves (none leaves text components either), is where the part is going.
Before you start
Anatomy, for the threads: built-in registries are frozen before either program exists, data-pack loading runs on the worker pool with hops back to the owning thread, and a reload’s apply phase runs on whichever thread owns the state being replaced.
Watch in this order
- Codecs, NBT and JSON — one
ItemStackwritten four ways: into a chunk file, into a packet, as a checksum in a click, and out of the text of a/give. The click sends no component values at all, only hashes. - Identifiers and registries — how
minecraft:diamond_swordbecomes anItembefore the game exists, and how a data-pack biome becomes aHolderthe client is told about. The wire id of a sword is its place in the order the items were registered. - The resource system — F3+T as a pipeline: a stack
of packs, a snapshot of the list, every listener preparing at once,
applying in order;
/reloadas the same pipeline on the server. On the client a failed reload deselects every pack it can, not the bad one. - Tags —
#minecraft:logsfrom a JSON file to the set a parrot checks before it perches. A frozen registry’s contents never change, and yet/reloadchanges what the tag contains. - Data components — the prototype an item type supplies and the patch a stack carries. The prototype is built on every reload, with the world’s registries in hand, not in the constructor.
- Text components — a death message built on the server, sent as a translation key, and worded by the client’s language file. The client receives it before it knows what it says.
- The data-driven type pattern — the type field
that picks a kind, a recipe’s or a loot function’s, is a lookup in a
built-in registry of kinds. Fifty-six of the registries in
BuiltInRegistriesare read that way, and they are why a pack can compose the game’s behaviours without adding one.
Where the part stops
Every page here follows a scenario, so each names the classes its own
scenario touches and names the families beside them without walking every
member — the concrete Tag classes one per primitive, the fifteen partial
component predicates, the read-only Holder views. Two
omissions are deliberate: net/minecraft/util is a grab-bag rather than a
system, named only where a page needs one of its helpers, and
net/minecraft/core/dispenser is eleven dispense behaviours, the interface
they share and the record each is handed, which the book names nowhere because what a dispenser is
belongs to Part V and what each behaviour does is a catalogue. Between them
that leaves a floor: 23% of the part’s lines are named on no page in the book.
Save migration, the largest thing in net/minecraft/util, is outside that
count altogether, out of scope by the newest-version-only rule
(what this book skips has the boundary
in full).
Reference this part uses
Math and primitives — the
coordinate spaces, packings, shapes and random sources every page assumes;
it was a Part II page and is now looked up, not watched.
Registries — every registry key but the root’s: built-in, data-pack, synced. Data components — every
DataComponentType in DataComponents. Naming drift —
Identifier was ResourceLocation. Diagram lanes
— the abbreviations every sequence diagram’s lanes are written with.
The glossary is worth more here than in any
other part: this is where the vocabulary every later part leans on is defined, and it is
the page to check when a later part uses one of these words in a second
sense.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Codecs, NBT and JSON
Verified against Minecraft 26.3 · Part II · One
ItemStackwritten four ways: into a chest’s chunk file, into a container packet, as a checksum in a click, and out of the text of a/give.
A player types /give @s diamond_sword[damage=5], drops the sword into a
chest, logs out, comes back and clicks the slot. In those few seconds the
same sword has been written four times into four different shapes: parsed
out of the square brackets, written into the chunk file the chest lives in,
sent down a socket as bytes, and sent back up when the slot was clicked.
The click is the one worth stopping on. It carries no component values at
all. It names the item and the count in the clear —
HashedStack.ActualItem is a Holder<Item>, an int and a hashed patch —
and for each component the sword’s patch adds it sends one 32-bit checksum,
produced by running that component’s own codec into HashOps, a
DynamicOps whose output is a hash rather than a document. And it is the same
codec every time. The codec that hashed the damage value is the codec that
wrote it into the chunk file and the codec that parsed it out of the square
brackets. One description of a type, and the format is an argument.
The cast
| class | what it decides | thread |
|---|---|---|
Codec | DataFixerUpper’s description of a type: encode to and decode from any format, given the ops for it | any |
DynamicOps | what a format’s map, list, string and number are made of — the argument a codec takes | any |
NbtOps | tags: the binary tree on disk, and what SNBT text parses into | any |
RegistryOps | the registry lookup a codec over a dynamic registry needs, wrapped around another ops | any |
HashOps | the format whose finished document is a hash; nothing is serialised on the way | Render on the client, Server on the comparison |
StreamCodec | the exception: hand-laid bytes on a Netty ByteBuf, written once, read once | Netty |
TagValueOutput · TagValueInput | the only ValueOutput and ValueInput there are — a CompoundTag plus its ops, and what save code sees | whichever thread saves |
ProblemReporter | that a codec failure inside a save is a logged path, not an exception in the tick | the failing thread |
All of the game’s part of it ships in both jars. The thread column uses the names anatomy gives the threads, and which one holds a codec matters only where the same codec runs on two of them.
The four paths, side by side
| into the chunk file | onto the wire | back as a checksum | out of the text | |
|---|---|---|---|---|
| who starts it | ChestBlockEntity.saveAdditional, inside chunk serialisation | ClientboundContainerSetSlotPacket, from AbstractContainerMenu.broadcastChanges | MultiPlayerGameMode.handleContainerInput, on the click | GiveCommand through ItemArgument |
| the ops | RegistryOps over NbtOps | a RegistryFriendlyByteBuf, and RegistryOps over NbtOps wherever a component’s wire codec is a Codec run into NBT | RegistryOps over HashOps.CRC32C_INSTANCE | RegistryOps over NbtOps, held by a TagParser |
| the codec | ItemStack.MAP_CODEC, inside ItemStackWithSlot.CODEC | ItemStack.OPTIONAL_STREAM_CODEC | each component’s own codec, through TypedDataComponent.encodeValue | DataComponentType.codecOrThrow, one component at a time |
| what is carried | a document — id, count, components | a count, an item id, then a DataComponentPatch | one int per added component, and the bare registry ids of the removed ones | SNBT text, then a Tag |
| the thread | Server, then the worker pool, then an IO worker for the file itself | Netty | Render on the client, Server on the comparison | Server |
| when it fails | a problem is recorded on a ProblemReporter and logged when the scope closes | the decoder throws, Connection.exceptionCaught sees it, and the connection drops | the hash disagrees, and the server sends the slot back | a CommandSyntaxException with the cursor position in it |
Four columns, four diagrams. Read them as four answers to the same question.
Disk: a chest writes a list of slots
sequenceDiagram
participant CBE as ChestBlockEntity
participant CHelp as ContainerHelper
participant TVO as TagValueOutput
participant NbtIo as NbtIo
Note over CBE: the server thread, inside chunk serialisation
CBE->>TVO: createWithContext, from a ProblemReporter and the registries
CBE->>CHelp: saveAllItems, handed the ValueOutput straight on
CHelp->>TVO: list Items with ItemStackWithSlot.CODEC, one entry per occupied slot
TVO->>TVO: ItemStack.MAP_CODEC writes id, count and components
TVO-->>CBE: buildResult gives a CompoundTag
Note over NbtIo: an IO worker, later
NbtIo->>NbtIo: write, from RegionFileStorage: the chunk's tag into its region file
The disk path, and the thing to notice is what has no lane: ItemStack
itself. Nothing here is a save method on a stack — an output object is made
with context at the top, handed down two levels, and hands back its
CompoundTag on the way out, with
ProblemReporter.ScopedCollector.close logging anything that went wrong as
the scope shuts.
ItemStack has no NBT method — there is no save and no parse on it.
The ValueOutput the whole chain writes into is one
TagValueOutput.createWithContext, made at the top with a ProblemReporter
and the registries, whose CompoundTag TagValueOutput.buildResult hands
back on the way out.
ChestBlockEntity.saveAdditional receives that ValueOutput and calls
ContainerHelper.saveAllItems, which opens a typed list under Items with
ItemStackWithSlot.CODEC, a record of slot plus the stack’s own
ItemStack.MAP_CODEC fields inlined. That map codec writes id, count
(defaulting to 1) and components, the last being
DataComponentPatch.CODEC, which spells a removal as !minecraft:foo.
NbtUtils.addCurrentDataVersion stamps the data version on the way out.
Load is the mirror: BlockEntity.loadStatic reads the id,
ChestBlockEntity.loadAdditional calls ContainerHelper.loadAllItems over
ValueInput.listOrEmpty.
Wire: a count, an id and a patch
sequenceDiagram
participant PEnc as PacketEncoder
participant IStack as ItemStack
participant DCP as DataComponentPatch
participant PDec as PacketDecoder
Note over PEnc: Netty, clientbound
PEnc->>IStack: OPTIONAL_STREAM_CODEC, a varint count where anything non-positive means empty
IStack->>DCP: Item.STREAM_CODEC for the id, then STREAM_CODEC for the patch
DCP-->>PEnc: added count, removed count, each type with its value, then the removed ids
Note over PDec: Netty, serverbound, the creative slot alone
PDec->>IStack: validatedStreamCodec over OPTIONAL_UNTRUSTED_STREAM_CODEC
IStack->>IStack: re-encode through ItemStack.CODEC into NullOps, keeping only the errors
The wire path, in both directions, and the two halves are not symmetrical:
the clientbound half above the second note lays the stack’s own fields into
the buffer by hand, in a fixed order, while the serverbound half runs a
Codec it does not want the output of, purely to see whether it throws.
The clientbound half is laid by hand. ItemStack.OPTIONAL_STREAM_CODEC writes
a varint count where anything non-positive means empty and nothing else
follows, then a registry id that resolves because the buffer is a
RegistryFriendlyByteBuf, then DataComponentPatch.STREAM_CODEC, which
writes each value with its component’s own wire codec — a varint for the
damage, and for a component with no wire codec of its own, its Codec run
into NBT.
ItemStack.STREAM_CODEC is the same codec that refuses an empty stack.
Serverbound is a different animal:
ServerboundSetCreativeModeSlotPacket uses ItemStack.validatedStreamCodec
over ItemStack.OPTIONAL_UNTRUSTED_STREAM_CODEC, and the decoded stack is
re-encoded through ItemStack.CODEC
into NullOps — output thrown away, only the errors kept — to prove that
the persistent codec would have accepted it. Why that packet in particular is
fenced, and what the other two fences are, belongs to packets and stream
codecs.
Checksum: a hash instead of a stack
sequenceDiagram
box transparent the client
participant CPL as ClientPacketListener
participant MPGM as MultiPlayer<br/>GameMode
end
box transparent the server
participant SP as ServerPlayer
participant ACM as Abstract<br/>ContainerMenu
end
Note over CPL: once, when configuration ends
CPL->>CPL: a RegistryOps over <br/>HashOps.CRC32C_INSTANCE,<br/>from its own registries
Note over SP: and the same ops, built the same way, from the server's
Note over MPGM: the Render thread, on the click
MPGM->>ACM: ServerboundContainerClickPacket: a HashedStack per changed slot
Note over ACM: the server thread, after the click has been re-run
ACM->>SP: the same hash of the server's own stack
ACM->>ACM: RemoteSlot.<br/>Synchronized <br/>compares the two
Two machines computing the same number the same way, which is the only reason the comparison means anything. The packet carries no component values at all — one int per added component and the removed types by registry id — so a client that has been lied to about a stack cannot hash its way back to agreement.
HashOps is a DynamicOps like any other, and a codec cannot tell the
difference: it builds maps and lists and strings as usual, and what comes
back at the end is a hash code rather than a tree. There is no intermediate
byte form to hash. Only one instance is ever built,
HashOps.CRC32C_INSTANCE, and both sides wrap it in a RegistryOps —
ClientPacketListener builds one from the registries it received during
configuration, ServerPlayer from the server’s own — because a component
value can name a registry entry, and a hash of an unresolvable name is no
hash at all. The server’s is behind a 256-entry cache keyed on the
TypedDataComponent, so the common components are hashed once per player
and then looked up. Removals are not hashed: HashedPatchMap is a map of
added component type to int plus a plain set of removed types. What the
server does with the comparison — the either-or in
RemoteSlot.Synchronized, and the promotion to a concrete stack when the
hash agrees — is containers and menus.
Text: square brackets into a Tag
sequenceDiagram
participant IP as ItemParser
participant TagP as TagParser
IP->>TagP: create once, as the command is built, over RegistryOps on NbtOps
Note over IP: the server thread, while Brigadier parses the command line
IP->>TagP: parseAsArgument at each component's value
TagP-->>IP: a Tag, read no further than the value
IP->>IP: DataComponentType.<br/>codecOrThrow parses that Tag
The shortest of the four paths, and the one that ends where the first
began: the Tag the parser hands back goes through the very codec the chunk
file used. Like the disk and checksum paths, it reaches a component’s codec by
asking the DataComponentType for it.
ItemArgument hands GiveCommand an ItemInput, and ItemParser is what
builds it. The parser holds a RegistryOps over NbtOps.INSTANCE and a
TagParser created for that ops, so [damage=5] becomes a Tag and
then goes through the very codec the chunk file used, reached by
DataComponentType.codecOrThrow. A leading ItemParser.SYNTAX_REMOVED_COMPONENT
is the command-line spelling of the same removal the disk codec writes.
Data packs are the JSON twin of this path:
RegistryDataLoader decodes every registry file in a data pack, loot
tables among them, through a RegistryOps over JsonOps.INSTANCE, and a
set_components function in a loot table reads its patch with
DataComponentPatch.CODEC, the codec a stack’s components go through on
disk.
One abstraction, and the ops that are not formats
A Codec is a description of a type and nothing else; it does not know
what it is writing into. The format is the DynamicOps handed to it at the
call, so the same object describes NBT on disk, JSON in a data pack, and
SNBT typed at a command line. Most of the game’s codecs are records built with
DataFixerUpper’s RecordCodecBuilder, from primitives and the combinators in
ExtraCodecs, the game’s own vocabulary in net/minecraft/util; the class index is
where to look one of them up.
Two of the game’s ops are not formats at all. HashOps, in the cast above,
answers every question an encoding codec asks and returns a checksum instead
of a document. NullOps, which the cast does not list, returns Unit: it
encodes to nothing, and exists so
that a codec can be run for its errors alone, which is exactly what
ItemStack.validatedStreamCodec does to a creative-mode stack.
StreamCodec is the genuine exception, and it is not a Codec at all.
A StreamCodec pairs an encoder and a decoder over a ByteBuf directly,
with StreamCodec.composite building one out of field codecs; the
catalogue of primitives is ByteBufCodecs. A packet is written once, read
once and must be small, so it gets hand-laid bytes rather than a document
in some format. The two worlds meet at ByteBufCodecs.fromCodec and
ByteBufCodecs.fromCodecWithRegistries, which run an ordinary Codec into
NBT and put the tag on the wire; the composing vocabulary on the far side of
that meeting belongs to packets and stream
codecs.
Where the registry context comes from
A codec that names an entry of a dynamic registry cannot resolve it on
its own. RegistryFixedCodec demands a RegistryOps, and RegistryFileCodec
and HolderSetCodec need one for a reference or a tag — a DelegatingOps
carrying a RegistryOps.RegistryInfoLookup beside whatever real ops it wraps. There
are two routes worth knowing: HolderLookup.Provider.createSerializationContext,
which is what nearly every caller uses, and RegistryDataLoader.createContext,
used during registry loading itself, when the registries are still being
built (identifiers and registries). Both
end at RegistryOps.create, which a handful of callers reach directly.
A codec over a built-in registry is a different case:
Registry.holderByNameCodec — and so Item.CODEC — resolves against the
registry instance captured inside the codec, and works on bare
NbtOps.INSTANCE. The distinction is not academic. It decides which paths
must build a context before they can decode anything, and in practice
almost every real path must: TagValueOutput.createWithoutContext exists
and is called by nothing at all, and there is no context-free ValueInput
even in principle.
On the network the same context arrives as a decorator.
RegistryFriendlyByteBuf adds RegistryFriendlyByteBuf.registryAccess to a
FriendlyByteBuf, and RegistryFriendlyByteBuf.decorator is bound when the
protocol switches from configuration to play. Configuration packets have no
registry context — which is why registry data and tags are sent in that
phase as NBT and ids, and why the ByteBufCodecs.holder family is play-only
(protocol phases).
What NBT actually is
Tag is a sealed interface, and the scalar leaves are records.
| branch | members | notes |
|---|---|---|
CompoundTag | — | a final class; the only keyed shape |
CollectionTag | ListTag, ByteArrayTag, IntArrayTag, LongArrayTag | the arrays are final classes, ListTag is a list |
PrimitiveTag | StringTag and the six NumericTag records | records of one value each |
EndTag | — | the terminator |
Because the leaves are records, the old getAsInt family is gone:
Tag.asInt and Tag.asString return Optional, CompoundTag.getInt is
Optional and CompoundTag.getIntOr is its defaulted form,
CompoundTag.get is still nullable, and the two-argument contains taking
a type id no longer exists.
Three things about the binary form surprise people.
A mixed-type list is boxed on the way out. Binary NBT stores one
element type per list, so ListTag.write promotes a heterogeneous list to
compounds and wraps each element in a one-entry compound under the empty
key, with ListTag.addAndUnwrap the exact inverse on read. This is not a
legacy artefact left lying around — it is written today, and it is the whole
reason mixed lists are legal at all.
A numeric array stays an array, but nothing turns a list into one.
NbtOps.createCollector hands back a NbtOps.GenericListCollector for an empty
list and only reaches for NbtOps.ByteListCollector, NbtOps.IntListCollector or
NbtOps.LongListCollector when it is handed an existing ByteArrayTag,
IntArrayTag or LongArrayTag — and those degrade back to the generic
collector the moment an element does not fit. A codec building a fresh list
gets a ListTag, whatever is in it; the arrays on disk are written by the
codecs that asked for arrays.
A whole file need not be read to answer a question about it.
StreamTagVisitor and the visitors beside it let NbtIo.parse pull a named
handful of fields out of a region chunk without materialising the chunk: a
CollectFields is built from the FieldSelectors the caller wants, two for
the IOWorker reading a chunk’s data version and its blending data, three
for StructureCheck, which is how it answers without loading a chunk; the
world-list screen does the same to level.dat with SkipFields.
Every read that came from outside carries a budget. NbtAccounter is
charged as the per-type read strategy in TagType walks the stream, with
NbtAccounter.DEFAULT_NBT_QUOTA at 2 MiB,
NbtAccounter.UNCOMPRESSED_NBT_QUOTA at 100 MiB and a depth cap of
NbtAccounter.MAX_STACK_DEPTH; overrunning any of them raises an
NbtException. Region compression is a separate global:
RegionFileVersion.configure sets one process-wide selection from
region-file-compression, a RegionFile captures it once for writing,
but every chunk carries its own version byte and reads honour that — so one
world can hold chunks in several compressions at once.
RegionFileVersion.VERSION_CUSTOM (id 127) is the marker for a chunk
written with a compression the game does not name; the marker for a chunk
that grew too big and lives in its own external file is a different one —
RegionFile’s external-stream flag, 128, set in the version byte. And
NbtIo.writeCompressed (GZIP)
is for the standalone files, level.dat and player data.
The text form is TagParser, and it is generic over its output ops
rather than producing tags: SNBT can decode straight into any format’s
target without a CompoundTag in between. TagParser.parseCompoundFully
is the plain-string entry point; TagParser.FLATTENED_CODEC accepts an
SNBT string only, and TagParser.LENIENT_CODEC, built as an alternative of
the two, is the one that takes a string or an object interchangeably.
Under it, SnbtGrammar is a packrat grammar and SnbtOperations supplies
the built-in bool and uuid functions.
Save code never sees a CompoundTag
ValueOutput and ValueInput are the façade every BlockEntity and
Entity writes through — ValueOutput.store with a codec,
ValueOutput.child, ValueOutput.list, and typed getters with defaults on
the way back in (ValueInput.getIntOr, ValueInput.getStringOr). The only
implementations are TagValueOutput and TagValueInput, which wrap a
CompoundTag and a DynamicOps, with ValueInputContextHelper holding the
shared provider and the empty instances. The CompoundTag-returning methods
on BlockEntity are final shells that build one of these, and they differ
only in how much metadata they add: BlockEntity.saveCustomOnly none,
BlockEntity.saveWithoutMetadata the block entity’s own components as a
full DataComponentMap, and BlockEntity.saveWithFullMetadata the id and
x, y and z. BlockEntity.saveWithId adds the id too, but only in the
ValueOutput form — it has no CompoundTag shell.
The point of the façade is that an encode failure is reported, not
thrown. Everything goes through a ProblemReporter, and
ProblemReporter.ScopedCollector logs the whole collected tree of problems
when it closes, rooted at BlockEntity.problemPath or Entity.problemPath;
TagValueOutput.EncodeToFieldFailedProblem and its siblings are what a bad
codec produces. An item that will not serialise costs you the item, not the
tick. The deliberate exceptions to the façade are CustomData and
TypedEntityData, the two components that carry a CompoundTag verbatim so
that data packs have an escape hatch
(data components).
Migration is the one thing that runs before any of this. DataFixTypes is
the enum of every kind of file the game owns — DataFixTypes.CHUNK,
DataFixTypes.PLAYER, DataFixTypes.LEVEL, DataFixTypes.OPTIONS and the
rest — and each calls DataFixTypes.updateToCurrentVersion on the
DataFixerUpper DataFixer from DataFixers.getDataFixer before the codec is
shown the tag, using the data version NbtUtils.addCurrentDataVersion
stamped when the file was written. The fixes themselves are out of scope
here.
Trusted, untrusted and validated
The wire’s own vocabulary for how far to trust a document belongs to
packets and stream
codecs:
the trusted/plain pairs are a read budget chosen by direction, and the
creative slot is the one packet that carries an arbitrary stack and is fenced
three ways for it. What belongs here is the fact that stands behind those
fences: this page’s four paths run one ItemStack four ways, and the
creative slot’s is the only one where the codec is run for its errors
rather than its output. ItemStack.validatedStreamCodec re-encodes a decoded
stack through ItemStack.CODEC into NullOps and keeps nothing but the
problems — the persistent codec is used as a validator for the wire one,
which is why a creative-mode stack cannot carry a component the disk would
reject.
JSON, the third format in the title and the smallest on the wire
JSON is the format with the smallest footprint. It is the data packs, and
on the wire it survives in exactly two places, both outside the play phase:
ClientboundStatusResponsePacket and ClientboundLoginDisconnectPacket,
sent through ByteBufCodecs.lenientJson. That is why the game ships two
JSON parsers — LenientJsonParser for the wire and StrictJsonParser for
data packs. Neither is where most JSON reading happens, though. GsonHelper
is the toolbox — sixty-nine static helpers, most of them pulling one typed
field out of a parsed object and naming the field in the exception when it is
missing or the wrong shape — and it is the pre-codec way of reading JSON,
still called from 124 places: the model loaders, the particle definitions,
the operator, allow and ban lists, and the data fixers. It sits outside the
codec pipeline but for one length check inside
ComponentSerialization.flatRestrictedCodec; a field it reads is read by a
hand-written parser and a field a codec reads is not. Chat text itself is NBT by the time
it reaches the play phase:
ComponentSerialization holds that whole matrix in one class
(text components).
Questions players ask
Why does an item look identical and not stack? Because equality is prototype plus patch, and the patch is what these codecs round-trip. Two stacks that took different routes to the same components compare equal; one that kept a component the other dropped does not, however the tooltip reads.
Why can a data pack put arbitrary NBT on an item at all? Because
CustomData and TypedEntityData are components whose value is a
CompoundTag, passed through verbatim. Everything else on a stack has a
real codec and is checked by it.
Why does a corrupt item vanish instead of crashing the world? Because
the save façade reports rather than throws. The bad field becomes a problem
on a ProblemReporter and is logged when the scope closes, and the chunk
saves without it.
Why is a creative-mode item the one thing the server double-checks? Because it is the only packet on which a client authors a whole stack. Everywhere else the client either receives stacks or asserts hashes about stacks the server already sent it.
Why can a world hold chunks in two different compressions? Because the
compression setting is captured per RegionFile for writing only, and every
chunk stores the version byte it was written with. Reads honour the byte.
Where to look
Codec · DynamicOps · ExtraCodecs · RegistryOps ·
HolderLookup.Provider.createSerializationContext · Tag · CompoundTag ·
ListTag · NbtOps · NbtIo · NbtAccounter · TagParser ·
StreamTagVisitor · HashOps · NullOps · ValueOutput · ValueInput ·
TagValueOutput · ProblemReporter · BlockEntity (the save shells) ·
ContainerHelper · ItemStackWithSlot · ItemStack (the codec fields) ·
StreamCodec · ByteBufCodecs · RegistryFriendlyByteBuf ·
PacketEncoder · HashedPatchMap · ItemParser · DataFixTypes ·
RegionFileVersion
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Identifiers and registries
Verified against Minecraft 26.3 · Part II · A player types
/give @s minecraft:diamond_sword, and the sword reaches their inventory as the number of the line it was registered on.
A player types /give @s minecraft:diamond_sword. The string on the right
is parsed into an Identifier, the identifier is paired with
Registries.ITEM to make a ResourceKey, and the key is looked up in a
table the server froze at startup, before any world existed —
BuiltInRegistries.ITEM. The stack that then lands in the player’s
inventory does not carry the name. Item.STREAM_CODEC is
ByteBufCodecs.holderRegistry over Registries.ITEM, and it writes an
integer. That integer is a line number. MappedRegistry.byId is an
insertion-ordered list, MappedRegistry.register appends to it, and an
entry’s numeric id is its position — so the wire id of a diamond sword is
where its line falls in Items, reordering two lines in Blocks changes a
block’s wire id, and a resource pack cannot. A dynamic registry, a biome
from a data pack, gets its numbers the other way: its elements are decoded
in parallel but registered in sorted order of their ids, so the server’s
numbering does not depend on which file finished first. The client does not
re-derive those numbers at all — it registers what the server sent, in the
order the packet lists them.
The cast
| class | what it decides | thread |
|---|---|---|
Identifier | the name — namespace and path — and which strings are legal ones | any |
ResourceKey | the name paired with the registry it belongs to, interned so equal keys are one object | any |
Registry · MappedRegistry | the table: key to object to integer, the frozen flag, the two tag tables | elements written on the launching thread or a load task’s worker, tags by whichever thread applies them; read from anywhere |
Holder | a reference to an entry that may be handed out before the entry exists (Holder.Reference), or an inline value that belongs to no registry (Holder.Direct) | any |
HolderLookup.Provider · RegistryAccess | the read-only view a codec resolves against: all the registries it may name | any |
BuiltInRegistries | the static registries — created empty at class init, filled and frozen by Bootstrap.bootStrap | the launching thread, before any server or client object exists |
RegistryDataLoader | the dynamic registries — one load task per registry, JSON from the packs on the server; on the client NBT from the wire, or its own jar’s JSON for a pack both sides know | Util.backgroundExecutor |
LayeredRegistryAccess | which layer may see which: four RegistryLayers on the server, two ClientRegistryLayers on the client | the server thread owns MinecraftServer.registries; the Render thread owns the client’s |
All of this is server and client: MappedRegistry, BuiltInRegistries
and RegistryDataLoader ship in the dedicated server jar. Client-only, of
the classes this page names, are ClientRegistryLayer,
RegistryDataCollector, KnownPacksManager and the client’s
CommonListenerCookie — and, less surprisingly,
ClientPacketListener, ClientConfigurationPacketListenerImpl and
IntegratedServer.
The name
Identifier, in net/minecraft/resources, is a namespace and a path, and
it is the class a 1.21-era reader knows as ResourceLocation. It is not a
record: a final class with a private constructor whose validation is an
assertion on the trusted path and a real check on the parsing paths
(Identifier.isValidNamespace, Identifier.isValidPath). The two
character sets differ, and the difference bites: a path may contain
/, a namespace may not, and .. is rejected outright as a namespace.
Identifier.DEFAULT_NAMESPACE is minecraft, and
Identifier.withDefaultNamespace supplies it — as does parsing a string
with no separator at all, or one that starts with the separator.
Identifier.parse throws IdentifierException (which lives in
net/minecraft, not beside Identifier) where Identifier.tryParse
returns null. Identifier.read exists twice, once returning a DataResult
for Identifier.CODEC and once over a Brigadier StringReader, with
Identifier.readNonEmpty beside it, and Identifier.resolveAgainst
carries a path-traversal guard. The one that surprises people:
Identifier.compareTo orders by path first and namespace second, so a
sorted list of ids is not grouped by mod.
A ResourceKey is an Identifier paired with the Identifier of the
registry it belongs to, and keys are interned through a weak map keyed
by ResourceKey.InternKey, so two keys for the same registry and id are
literally the same object. Registries holds the 156 ResourceKeys of
registries (Registries.ITEM, Registries.BIOME …) — 155 distinct
objects, because two of the keys are one: Registries.DIMENSION and
Registries.LEVEL_STEM are both built from the string dimension, so
interning hands back one object under two names and two (unchecked) element
types — Registries.LEVEL_STEM is the data-pack registry the
RegistryLayer.DIMENSIONS layer loads, Registries.DIMENSION keys the
ServerLevels, and the conversion helpers between them are identity
functions at run time. Five more registry keys are declared by the class
that owns them rather than here, which is why the catalogue’s total is 161
and this one is 156 (registries). Interning
is what makes identity work: ResourceKey declares no equality of its own, so
MappedRegistry.byKey, MappedRegistry.registrationInfos,
Holder.Reference.is for a ResourceKey and every == against a key like
Level.OVERWORLD rely on equal keys being one object. BlockIds, ItemIds
and BlockItemIds (net/minecraft/references) hold the per-element keys the
block and item initialisers use.
The table
Registry is the read interface — Registry.getValue, Registry.getKey,
Registry.getId, Registry.getTags, and the codecs Registry.byNameCodec
and Registry.holderByNameCodec — and it extends IdMap, so every registry
is also an int-to-object table. WritableRegistry adds
WritableRegistry.register and WritableRegistry.bindTags;
DefaultedRegistry answers a default entry — air for items and blocks —
instead of null. MappedRegistry is the one real implementation, with
DefaultedMappedRegistry its only subclass. It holds the same entries in
five indexes at once, and every lookup direction is one of them:
MappedRegistry.byKey and MappedRegistry.byLocation from a name, the
insertion-ordered MappedRegistry.byId from a number, and, from the object
itself, the two identity maps MappedRegistry.byValue — what answers
Registry.getKey — and MappedRegistry.toId, which returns −1 for anything
it has never seen, an equal-but-distinct object included. It carries the
MappedRegistry.frozen flag that MappedRegistry.validateWrite checks on
every ordinary write.
A Holder is the seam between a registry and the code that names its
entries. It is a sealed interface with two kinds (Holder.Kind).
Holder.Reference is an entry in a registry — and is itself
non-sealed: it knows its HolderOwner, its key, its tags and its
components, and any of those may be unbound until the registry binds them
(Holder.Reference.bindValue, Holder.Reference.bindTags,
Holder.Reference.bindComponents). A reference is a promise:
Holder.Reference.value throws until Holder.Reference.bindValue has run,
codecs hand these out freely during a load, and the freeze is what makes
every promise kept — or fails loudly. Holder.Direct is a record wrapping
an inline value and a DataComponentMap that belongs to no registry: it has
no key, is in no tag, and serialises inline. A HolderSet is a set of
holders — HolderSet.Named is a tag, HolderSet.Direct a literal list.
What a codec sees is a read-only view. HolderGetter, HolderLookup and
HolderOwner are those views — and HolderOwner exists for exactly one
question, HolderOwner.canSerialize: the context asking to serialise a
holder answers whether it is that holder’s owner, which is why a holder from
one world refuses to be written by another; HolderLookup.Provider is “all the
registries I may resolve against” and HolderLookup.RegistryLookup is one
of them. RegistryAccess is a HolderLookup.Provider over a set of
registries, and RegistryAccess.Frozen is a bare marker for the finished
kind. Every static initialiser writes its elements through Registry.register
or Registry.registerForHolder (the registries themselves go into the root
through WritableRegistry.register); every
codec that names a dynamic entry — RegistryFileCodec,
RegistryFixedCodec, RegistryCodecs.holderSet, HolderSetCodec —
resolves through a RegistryOps (codecs, NBT and JSON);
and at runtime MinecraftServer.registryAccess and
ClientPacketListener.registryAccess are where anything that must resolve a
key goes, except the server’s reloadable registries, which
MinecraftServer.reloadableRegistries holds.
Before the game exists
sequenceDiagram
participant Boot as Bootstrap
participant BIR as BuiltInRegistries
participant Items as Items
participant Item as Item
participant DMR as Defaulted<br/>MappedRegistry
Note over Boot,DMR: the launching thread, before any server or client object exists
Boot->>BIR: class init: one empty registry per key, 95 of the 156, each with a loader
Note over Boot,Items: the dispenser and cauldron bootstraps run first, so Items is already initialised
Boot->>Items: class init: registerItem per item, Item.Properties.setId stores the key
Items->>Item: new Item(properties), which knows its own key
Item->>DMR: createIntrusiveHolder: a Holder.Reference with a value, no key
Items->>DMR: register(key, item, BUILT_IN): bindKey, numeric id = byId.size()
Boot->>BIR: bootStrap: createContents runs every loader, then freeze, then validate
BIR->>BIR: freeze: the root, then each registry — tags bound empty, then MappedRegistry.freeze
DMR->>DMR: bindValue on every holder, refuse an unbound one, build componentLookup
Note over Boot,DMR: components and tags are bound together later, when a world loads
The bootstrap ladder, on one thread, before either program exists. The numeric id an item gets is the size of the registry when it registers — the line number of its registration — which is why the wire ids are stable only because the registration order is.
Both Main classes call Bootstrap.bootStrap early, after argument parsing
and crash-report preload (anatomy
has the order), on the launching thread before any server or client object
exists, and Bootstrap.isBootstrapped is set before any registry is touched. Bootstrap.bootStrap calls
BuiltInRegistries.bootStrap, and after it returns every built-in registry
is frozen and any WritableRegistry.register throws.
Registries exist before their contents. BuiltInRegistries class init
creates every registry empty and records the loader that fills it in
BuiltInRegistries.LOADERS, an insertion-ordered map. Bootstrap.bootStrap
then runs BuiltInRegistries.createContents — the loaders in that order.
By then Items, Blocks and EntityTypes are already initialised:
Bootstrap.bootStrap reaches FireBlock.bootStrap,
EntityTypes.PLAYER, the dispense behaviours’ bootstrap and
CauldronInteractions.bootStrap before it calls
BuiltInRegistries.bootStrap, and those touch Blocks, EntityTypes and
Items in that order — so
BuiltInRegistries.createContents finds the loaders’ values already there rather than
triggering them.
Bootstrap.checkBootstrapCalled is the guard that makes “touched Blocks
from a static initialiser” a crash rather than a silent empty registry; it
works because the bootstrap flag is set before the registries are touched,
not after.
The key travels in the properties. Items.registerItem takes a
ResourceKey from ItemIds or BlockItemIds and calls
Item.Properties.setId before constructing, so an Item knows its own key at
construction time. Blocks do the same with BlockIds and BlockItemIds and
BlockBehaviour.Properties.setId.
Five registries hand the object its own holder. BuiltInRegistries.BLOCK,
BuiltInRegistries.ITEM, BuiltInRegistries.FLUID,
BuiltInRegistries.ENTITY_TYPE and BuiltInRegistries.BLOCK_ENTITY_TYPE
are created with intrusive holders: the constructor asks the registry for a
Holder.Reference (MappedRegistry.createIntrusiveHolder) that wraps the
object before it has a key, and stores it in Item.builtInRegistryHolder.
Registration then binds the key to that holder rather than creating a
new one, so Item.builtInRegistryHolder and the registry’s own holder are
the same object, and a tag check on a block or item is a set lookup on the
holder’s own bound tag set with no registry hop
(tags). Holder.Reference.createIntrusive is marked
deprecated — the mechanism is load-bearing but not encouraged.
The numeric id is the line number. MappedRegistry.register appends
to MappedRegistry.byId, and every static registration carries
RegistrationInfo.BUILT_IN — a Lifecycle and the KnownPack an entry
came from, which for a static entry is nothing. The wire id of an item is
the position of its line in Items, and Item.STREAM_CODEC encodes that
integer. A dynamic registry numbers the other way, and the difference is
worth holding: ResourceManagerRegistryLoadTask decodes its files in
parallel and then registers them in sorted order of their ids, so the
server’s numbering is the same whichever file finished first. The client
still never derives a number — it registers what the packet lists, in the
order the packet lists it — but because the server sorted, that order is
reproducible rather than accidental.
Freeze is a proof, stated in full below. BuiltInRegistries.freeze
freezes the root registry first, then every registry it holds, and
BuiltInRegistries.validate closes the bootstrap: an empty registry is
logged, a DefaultedRegistry whose default key is missing throws.
When a world opens
The server keeps its registries as a LayeredRegistryAccess in
MinecraftServer.registries, one layer per RegistryLayer —
RegistryLayer.STATIC, RegistryLayer.WORLD,
RegistryLayer.DIMENSIONS, RegistryLayer.RELOADABLE, in that order —
with MinecraftServer.registryAccess the flattened view.
LayeredRegistryAccess.getAccessForLoading is everything before a layer,
which is what that layer’s JSON may reference;
LayeredRegistryAccess.compositeAccess is everything. The client mirrors
this with two layers, ClientRegistryLayer.STATIC and
ClientRegistryLayer.REMOTE. On both sides the STATIC layer is
RegistryAccess.fromRegistryOfRegistries over BuiltInRegistries.REGISTRY
— a live view of the frozen root registry, not a copy of it.
flowchart TD
WL["WorldLoader.load, on Util.backgroundExecutor"]:::worker
RDL["RegistryDataLoader.load for the world<br/>registries, then the dimensions"]:::worker
T1["one RegistryLoadTask: feature"]:::worker
T2["one RegistryLoadTask: placed feature"]:::worker
T3["… one per RegistryDataLoader.RegistryData, 51 then 1"]:::worker
F["RegistryLoadTask.freezeRegistry binds every promise,<br/>then the RegistryValidator"]:::worker
LRA["LayeredRegistryAccess.replaceFrom:<br/>world and dimensions in one call"]:::worker
WL --> RDL
RDL --> T1
RDL --> T2
RDL --> T3
T1 -. "an unbound Holder.Reference" .-> T2
T2 -. "an unbound Holder.Reference" .-> T1
T1 --> F
T2 --> F
T3 --> F
F --> LRA
World load, as a task graph rather than a conversation: one task per
registry, all on the worker pool, each able to ask any other in the same call
for an element it has not registered yet. The dotted edges are the forward
references, the freeze is where they stop being promises, and the last box
runs on the worker pool too, not on the thread that called WorldLoader.load.
Later, a client logs in and reaches the configuration phase, and the server sends it what it just built.
sequenceDiagram
box transparent the server
participant SRT as Synchronize<br/>RegistriesTask
participant SCPL as ServerConfiguration<br/>PacketListenerImpl
end
box transparent the client
participant CCPL as ClientConfiguration<br/>PacketListenerImpl
participant RDC as RegistryData<br/>Collector
end
SRT->>CCPL: ClientboundSelectKnownPacks: which packs do you have?
CCPL->>SCPL: ServerboundSelectKnownPacks: accepted all-or-nothing
SRT->>CCPL: one ClientboundRegistryDataPacket per synced registry
Note over SRT,CCPL: an entry from a pack the client has carries no data
SRT->>CCPL: ClientboundUpdateTagsPacket: tags as registry ints
SCPL->>CCPL: ClientboundFinish<br/>ConfigurationPacket
CCPL->>RDC: collectGameRegistries: rebuild REMOTE, patch the static tags
RDC->>CCPL: a RegistryAccess.Frozen, into the play listener's cookie
Note over CCPL,RDC: with an IntegratedServer, that Frozen is thrown away<br/>for the server's own registries
CCPL->>SCPL: ServerboundFinish<br/>ConfigurationPacket
The same registries crossing to a client: SynchronizeRegistriesTask sends
the three data packets, and the fourth, which lets play begin, comes through
the listener from a later task, JoinWorldTask. The client decodes none of it
until that finish packet arrives, and the last note is the singleplayer case,
where the work of the whole exchange is discarded.
WorldLoader.load creates the resource manager on the main thread, runs
RegistryDataLoader.load on Util.backgroundExecutor, and returns to the main
thread for the final assembly; this is where the RegistryLayer.WORLD and
RegistryLayer.DIMENSIONS layers are filled, and the
RegistryLayer.RELOADABLE layer of the copy
MinecraftServer.reloadableRegistries holds. The configuration phase is
the third moment: SynchronizeRegistriesTask sends the dynamic registries
on the server thread, and the client rebuilds its
ClientRegistryLayer.REMOTE layer in
RegistryDataCollector.collectGameRegistries — decoding on the worker
pool, joined on the Render thread — before it will accept play packets.
Layers load against the layers before them. RegistryDataLoader.load
is given lookups built from LayeredRegistryAccess.getAccessForLoading —
built by TagLoader.buildUpdatedLookups, so that the static registries’
freshly read tags are visible to the worldgen codecs before they are
applied (tags) — so a biome JSON may reference a placed feature
(same layer) or a sound event (RegistryLayer.STATIC) but never a level
stem (RegistryLayer.DIMENSIONS, which loads after). The lists
RegistryDataLoader.WORLD_REGISTRIES,
RegistryDataLoader.DIMENSION_REGISTRIES,
RegistryDataLoader.RELOADABLE_REGISTRIES and
RegistryDataLoader.SYNCHRONIZED_REGISTRIES say which keys belong to which
step and which subset the client is told about. Both the world and the
dimensions layers are installed in a single LayeredRegistryAccess.replaceFrom
call, and the dimensions layer that wins is the world data’s, not
necessarily the one just decoded — a saved world’s dimension set survives.
Loading is a task graph. Each registry is one RegistryLoadTask
owning a fresh MappedRegistry and a lock-guarded
ConcurrentHolderGetter. RegistryDataLoader.createContext hands every
task’s getter to every other, so Biome.DIRECT_CODEC decoding on one
worker can ask for a carver that another worker is still
registering — the getter returns an unbound Holder.Reference, and the
reference is bound when that registry freezes. Forward references cost
nothing, even in a cycle — a feature can name placed features and a placed
feature its feature — because nothing waits on them.
Fourteen of the fifty-two world and dimension registries also carry a
RegistryValidator other than the empty one in their RegistryDataLoader.RegistryData, run after
the freeze — thirteen of them entity-variant registries running the same
check, RegistryValidator.nonEmpty, and the fourteenth Registries.TIMELINE,
whose Timeline.validateRegistry is its own.
Provenance is recorded per entry, and it is coarser than it looks.
ResourceManagerRegistryLoadTask gives each element a RegistrationInfo
naming the KnownPack it came from and a Lifecycle. The rule is
presence, not vanilla-ness: an element from any pack that reports a
KnownPack is stable, and only an element from a pack with no known-pack
info is experimental. KnownPack.isVanilla is computed on that path and
then discarded. Anything received over the network is experimental, and
the RegistryLayer.RELOADABLE layer is read by the same task under the same
rule; the registry’s own lifecycle is the merge of its entries’, and that merge is
what the “experimental features” warning on world open reads.
The client is told what it does not already have.
SynchronizeRegistriesTask first asks the client which KnownPacks it has
(ClientboundSelectKnownPacks). The request lists only the server’s packs
that declare one — PackLocationInfo.knownPackInfo is an optional, so a
world’s own datapack is simply absent from it — and the client matches them
against its bundled vanilla repository through
KnownPacksManager.trySelectingPacks. The comparison is all or nothing:
the
client’s answer must equal the request exactly, or every element of every
synced registry is sent in full. On a match,
RegistrySynchronization.packRegistries sends every element’s id but
leaves the NBT payload empty for entries from those packs, and the client’s
RegistryLoadTask.PendingRegistration.findAndLoadFromResource re-decodes
the JSON from its own jar. A modified biome from a custom data pack is sent
in full through Biome.NETWORK_CODEC — the network codec, which omits the
generation and mob-spawn settings the client never needs.
The client rebuilds one layer and patches the other.
RegistryDataCollector accumulates the packets, and
RegistryDataCollector.collectGameRegistries runs when configuration
finishes — dispatching the load onto a background executor against the
negotiated packs and then blocking on the result, so the decoding leaves the
Render thread, which waits for it, without the phase becoming asynchronous. The ClientRegistryLayer.REMOTE layer is rebuilt wholesale and
frozen — but the static registries cannot be rebuilt, so their tags are
applied in place, through the mechanism tags owns, and when no
registry data arrived at all the collector takes a tags-only path that
patches and returns the original registries untouched. The result is a
RegistryAccess.Frozen in CommonListenerCookie that every
RegistryFriendlyByteBuf in the play phase decodes against.
Singleplayer throws most of that away. When an IntegratedServer
exists, ClientConfigurationPacketListenerImpl.handleConfigurationFinished
substitutes the server’s own registries for the ones the client just built,
and the memory connection suppresses re-applying static tags and components
client-side. Both halves then share the same registry objects — which is
worth remembering whenever a page says “the client’s copy”.
The freeze rule, stated
A frozen registry’s contents never change. Its tags and its elements’ components do. Everything in this part that looks like an exception to the first sentence is one of the two things in the second.
MappedRegistry.freeze is a proof, not a switch. It binds every holder’s
value and throws if any holder is still unbound, if any intrusive holder
was created but never registered, or if any tag declared by a TagKey was
never bound. The tag half of that proof works because a registry keeps its
declared tags apart from its bound ones, which is the split
tags is built on: the freeze checks the
declared table and installs the bound one. For the static registries the
real tags do not exist until a data pack is read, so BuiltInRegistries.freeze
first binds the tags the bootstrap asked for to empty
(MappedRegistry.bindAllTagsToEmpty) and the proof passes on empty sets.
The freeze also builds MappedRegistry.componentLookup. After it, the
MappedRegistry.frozen flag makes MappedRegistry.validateWrite throw on
every ordinary write. Two things still change: MappedRegistry.prepareTagReload
requires the frozen flag, and the component prototypes are rebound beside
the tags (data components).
What changes afterwards changes through two doors. Tags: a world load
swaps the tag tables of the static registries, and /reload does more
than refill the RegistryLayer.RELOADABLE layer — it re-reads and
re-applies tags for every registry in the server’s composite access.
How a frozen registry’s tags are swapped is the pay-off of
tags, and the mechanics of the reload
itself belong to the resource system. Components: components are not part of
the freeze at all. Every registry element’s DataComponentMap is bound
after it by
Holder.Reference.bindComponents, and /reload rebinds every one of them;
data components owns how.
Feature flags: the same registry, narrowed
A frozen registry’s contents never change — but what a lookup will show you
can be narrower than what the registry holds, and that is most of the
feature-flag mechanism; the rest is a direct FeatureElement.isEnabled check
where an element is used. FeatureFlagSet is a bitmask: a 64-bit long and the
FeatureFlagUniverse it belongs to, with FeatureFlagSet.MAX_CONTAINER_SIZE
at 64 flags. There is one universe, main, and FeatureFlags declares four
flags in it — FeatureFlags.VANILLA and the three experiments,
FeatureFlags.TRADE_REBALANCE, FeatureFlags.REDSTONE_EXPERIMENTS and
FeatureFlags.MINECART_IMPROVEMENTS. A set that is not a subset of
FeatureFlags.VANILLA_SET is what FeatureFlags.isExperimental reports, and
what puts the warning on a world.
What carries a flag is a registry element. FeatureElement is an interface
whose one abstract method is FeatureElement.requiredFeatures, implemented by exactly
seven types — Item, BlockBehaviour, EntityType, GameRule, MenuType,
Potion and MobEffect — and FeatureElement.FILTERED_REGISTRIES names the
seven registries those live in. HolderLookup.RegistryLookup.filterFeatures
is the filter: handed the world’s enabled set, it returns the same lookup
untouched for any registry not in that set of seven, and a delegating lookup
that hides the disabled elements for one that is. The registry underneath is
not touched, and neither is its numbering — a disabled item keeps its wire id.
That is why an experiment is enabled per world and not per install. The set
is WorldDataConfiguration.enabledFeatures, read from level.dat, and the
filtered lookup is what CommandBuildContext hands every argument type, so
/give cannot name a disabled item and /setblock cannot name a disabled
block; GameRules builds its map from the filtered BuiltInRegistries.GAME_RULE,
so a disabled rule is not merely refused but absent; and LevelReader.holderLookup
filters for anything that asks a level for a registry. Where the flags come
from is the pack stack — a feature pack is a built-in pack carrying a
FeatureFlagsMetadataSection, and turning on an experiment is enabling a
data pack (the resource system).
What crosses the wire, and where the files are
Built-in registry elements never cross the network — both sides ran the
same static initialisers — but their tags do, and dynamic elements do:
ClientboundSelectKnownPacks and ServerboundSelectKnownPacks, then
ClientboundRegistryDataPacket (one per synchronised registry, entries as
RegistrySynchronization.PackedRegistryEntry) in the configuration phase,
then ClientboundUpdateTagsPacket, which is a common packet and arrives
again mid-play after a server /reload; and a registry element,
built-in or dynamic, crosses inside other packets as a bare varint id
resolved against the buffer’s registry access, except in the few packets
that send its key. Only one variant shifts that
numbering: ByteBufCodecs.holder reserves 0 for an inline Holder.Direct
and writes every registry id one higher, where ByteBufCodecs.holderRegistry
— which Item.STREAM_CODEC uses — writes the raw id. On disk, every key in
RegistryDataLoader.WORLD_REGISTRIES and
RegistryDataLoader.DIMENSION_REGISTRIES reads
data/<namespace>/<registry path>/*.json (Registries.elementsDirPath)
through FileToIdConverter.registry over a ResourceManager
(the resource system), its tags live under
Registries.tagsDirPath — there is a third path builder,
Registries.componentsDirPath, but it names a reports directory the data
generator writes and nothing in the running game reads — and the reloadable set (loot
tables, predicates, item modifiers, recipes, advancements and three more) is
read the same way, through ReloadableServerRegistries. Which registry is which kind is
reference/registries.
For a 1.21-era reader.
Identifierwas ResourceLocation, and the vanilla data you remember being registered in code is now read as JSON like anyone else’s:RegistrySetBuilder,BootstrapContextandVanillaRegistriesare the data generator that writes those files into the jar, and the running game only ever reads them (what this book skips has how far that is true). AndBlock.BLOCK_STATE_REGISTRYis not a registry:IdMapperis the standaloneIdMapbehind block-state ids and similar palettes, sharing an interface withRegistryand nothing else.
Where to look
Identifier · ResourceKey · Registries · Registry · MappedRegistry.register ·
MappedRegistry.freeze · DefaultedMappedRegistry · Holder · HolderSet ·
HolderLookup · Bootstrap.bootStrap · BuiltInRegistries.bootStrap ·
RegistryLayer · LayeredRegistryAccess · WorldLoader.load ·
RegistryDataLoader.load · RegistryLoadTask ·
ResourceManagerRegistryLoadTask · RegistryValidator ·
RegistrySynchronization.packRegistries · SynchronizeRegistriesTask ·
RegistryDataCollector.collectGameRegistries · NetworkRegistryLoadTask ·
ClientConfigurationPacketListenerImpl.handleConfigurationFinished · RegistryOps
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
The resource system
Verified against Minecraft 26.3 · Part II · A player presses F3+T, the screen goes to the logo and a bar, and every texture, model, sound and font is rebuilt from a stack of packs without the game stopping.
A player presses F3+T. The screen goes red, the Mojang Studios logo comes
up, and a white bar creeps across under it while the old world keeps
rendering behind. What is happening is one pipeline that everything the
game reads from a pack goes through — textures, models, sounds, language
strings, recipes, advancements, loot tables, tags, worldgen JSON: a stack
of packs is discovered, merged into a resource manager that is a
snapshot of the stack, and a list of reload listeners each rebuild
their world from it, preparing at once on the worker pool
(anatomy) where they
have anything to prepare, and swapping their live state on the owning thread
in the order they were registered. The client’s stack is resource packs (PackType.CLIENT_RESOURCES,
the assets tree); the server’s is data packs (PackType.SERVER_DATA, the
data tree) — same classes, two instances, two directories, and /reload
is the same pipeline run by the server. The surprising part is the end. A
reload that fails does not find the offending pack.
Minecraft.rollbackResourcePacks deselects every resource pack it can,
clears the options lists, saves, and reloads again — and if vanilla was the only
selected pack it rethrows and crashes instead.
The cast
| class | what it decides | thread |
|---|---|---|
PackRepository · Pack | which packs exist, which are selected, and in what order | whoever asks: Render on the client, Server on the server |
MultiPackResourceManager · FallbackResourceManager | which pack’s copy of a file wins, one stack per namespace | built on the asking thread, read from workers |
ReloadableResourceManager | the client’s long-lived façade: the current snapshot and the listener list | Render |
PreparableReloadListener · SimplePreparableReloadListener · SimpleJsonResourceReloadListener | what to read off-thread and what to swap on-thread | prepare on the worker pool, apply on the owner |
SimpleReloadInstance | the schedule: every prepare at once, every apply in order behind a barrier | built on the caller’s thread, barriers resolved on the owner |
LoadingOverlay | when the client’s reload is done, and what to do if it failed | Render |
ReloadableServerResources | the server’s one listener, and the registries that replaced the rest | Server |
The pipeline
flowchart TD
D["discover: PackRepository.reload re-runs every RepositorySource"] --> S["snapshot: a new MultiPackResourceManager over the opened packs"]
S --> P["prepare: the worker pool reads, all at once"]
P --> A["apply: each listener swaps its live state, in registration order"]
A -- "ReloadInstance.checkExceptions finds none" --> F["finish: the level re-extracted"]
A -- "a listener threw" --> R["roll back: every pack deselected"]
R -- "the reload run again" --> D
The five stages and the one branch, which comes at the end and not at the start. A snapshot is of the pack list, not of the bytes; the rollback arrow runs back to discover because what follows a failure is another whole reload, not a retry of the stage that failed.
The rest of the page is a section per stage: what comes in, what is decided,
what goes out. F3+T is the grounding trace; /reload is the coda, as a table
of where the server’s run of the same pipeline differs.
Discover: the repository and its packs
What comes in is a set of RepositorySources (server/packs/repository),
each a place packs are found. ClientPackSource and ServerPacksSource
are the built-ins, both extending BuiltInPackSource, which also lists the
packs bundled inside the vanilla pack — the art packs, the accessibility
packs and every feature pack (BuiltInPackSource.TESTS_ID names one more,
tests, which only a development build enables); FolderRepositorySource is a
directory of user packs; DownloadedPackSource is server-sent packs, client
only. PackRepository.reload re-runs every source into the available
map and then rebuilds the selected list: prior choices are kept, and a
pack whose Pack.isRequired is true is force-inserted at its
Pack.getDefaultPosition. Reading the order out of options.txt is a
startup step — Options.loadSelectedResourcePacks runs once in the
Minecraft constructor, not on every F3+T.
A Pack is a discoverable pack: a PackLocationInfo (id, title,
PackSource, optional KnownPack), a Pack.ResourcesSupplier that can
open it, its Pack.Metadata (description, PackCompatibility, requested
feature flags, overlays) and a PackSelectionConfig — required, default
Pack.Position, fixed. Pack.Position owns the insertion algorithm that
makes a fixed pack stick, and the list it inserts into runs from the bottom
of the stack to the top: Pack.Position.BOTTOM inserts at the front of
the list, past any pack already fixed there, and Pack.Position.TOP at the
back. The last pack in the list wins (next section), which is why vanilla
is BOTTOM and why “higher in the UI” means “later in the list”. The
client’s vanilla pack cannot be deselected and the server’s can:
ClientPackSource marks vanilla required and bottom; ServerPacksSource
marks it bottom but optional.
What a pack is on disk is PackResources (server/packs): the raw file
source with PackResources.getResource, PackResources.listResources and
PackResources.getNamespaces. FilePackResources is a zip;
PathPackResources a directory; OverlayedPackResources a pack plus its
overlays subdirectories, which the Pack.ResourcesSupplier assembles for
zip and folder packs. VanillaPackResources is not a PackResources
itself: its Pack.ResourcesSupplier hands out FixedPathPackResources
layers — the jar’s own assets and data, and on the client the launcher’s
asset index above them — and never an overlay. Discovery is guarded:
DirectoryValidator, ForbiddenSymlinkInfo and PackDetector decide
what a folder is allowed to be, allowed_symlinks.txt is parsed into a
DirectoryValidator by LevelStorageSource.parseValidator. One corner of
server/packs is worth a sentence here. packs/linkfs is a synthetic
read-only file system — LinkFileSystem, LinkFSProvider and a LinkFSPath
that is a name in a tree rather than a name on disk — which presents the
launcher’s asset files, stored under their hashes, as the one directory
tree their index describes, so the game can open a pack that was never
assembled.
What pack.mcmeta says
The file is read as ResourceMetadata sections: PackMetadataSection
(description and a PackFormat range), FeatureFlagsMetadataSection,
OverlayMetadataSection, ResourceFilterSection. Compatibility is a
range, not a number. PackMetadataSection carries an inclusive range of
PackFormat major/minor pairs and PackCompatibility reports too old, too
new, unknown or compatible against the game’s own — resource 97.1 and
data 121.0. Above PackFormat.lastPreMinorVersion (64 for
assets, 81 for data) the min_format / max_format fields are mandatory
and the old integer pack_format is not enough. A pack.mcmeta the strict
codec rejects gets one more chance through a description-only fallback so
the pack can at least be listed as incompatible. Overlays are versioned
sub-packs: OverlayMetadataSection maps a PackFormat range to an
overlays subdirectory that OverlayedPackResources layers on top of the
pack itself, so one zip can carry variants for several game versions.
Feature flags are packs, but not auto-selected ones. A feature pack is a
built-in pack carrying a FeatureFlagsMetadataSection, and its
PackSource deliberately reports that it must not be added
automatically. MinecraftServer.enableForcedFeaturePacks force-selects the
ones matching the world’s forced features, and the world’s flag set is the
selected packs’ requested flags joined with those forced ones. Turning on
an experiment is enabling a data pack — through a different door than
ordinary packs use. What the resulting FeatureFlagSet then gates is a
registry lookup, and that is
identifiers and registries.
What goes out of the stage is PackRepository.openAllSelected: the
selected Packs opened into a list of PackResources, in order.
Snapshot: the manager
MultiPackResourceManager (server/packs/resources) is built from that
list. It is a snapshot of the pack list, not of the bytes: it asks each
pack for its namespaces — the first half of every id, the name of a
directory under the pack’s assets or data root: minecraft for vanilla’s
own files and whatever its author chose for the rest — and
builds one FallbackResourceManager per namespace, each a stack searched
from the last selected pack down. The
old world stays up until the last apply, but the old files do not: on the
client, ReloadableResourceManager.createReload closes the previous
MultiPackResourceManager — with every file handle it held — before
building the new one. What is frozen is which packs are in the stack; a
Resource still opens its file when it is read, so a folder pack
edited on disk mid-reload is observable. A zip is not: FilePackResources
holds its zip open for the life of the pack.
A Resource is what a lookup returns: its source pack, an IoSupplier for
the bytes — opened lazily, at read time — and a lazily-read
ResourceMetadata found beside it. A .mcmeta is looked for in the
winning pack or those above it, never in one below, so a pack overriding a
texture without its .mcmeta loses the animation. ResourceFilterSection
lets a pack hide lower packs’ files by pattern without providing
replacements: MultiPackResourceManager reads each pack’s filter section
and pushes it onto the namespace stacks as a filter, and a lookup that
reaches a filtered entry stops there. Some loaders want every copy, not
the winner: ResourceManager.getResourceStack and
ResourceManager.listResourceStacks return all packs’ copies
bottom-first, which is how languages, tags and atlas sources merge
instead of overriding.
One more manager frames this one. ReloadableResourceManager is the
long-lived client façade that holds the current snapshot and the
PreparableReloadListener list; the server has no façade — each reload is
a fresh MultiPackResourceManager inside
MinecraftServer.ReloadableResources.
What goes out is one ResourceManager, wrapped in a
PreparableReloadListener.SharedState, and a ReloadInstance that has
already started, which is a SimpleReloadInstance, or its subclass
ProfiledReloadInstance when the logger is at debug.
Prepare: every listener at once
A PreparableReloadListener.reload takes the shared state (which carries
the ResourceManager), a background executor, a
PreparableReloadListener.PreparationBarrier and a main-thread executor.
SimplePreparableReloadListener splits that into
SimplePreparableReloadListener.prepare (background) and
SimplePreparableReloadListener.apply (main thread);
SimpleJsonResourceReloadListener is the “every JSON file in a directory
through one codec” specialisation, using FileToIdConverter to map
assets/ns/equipment/foo.json to ns:foo; ResourceManagerReloadListener is
the apply-only shape.
SimpleReloadInstance is the schedule, and this is what it does, read
from SimpleReloadInstance.prepareTasks. It wraps both executors in
counters. It calls PreparableReloadListener.prepareSharedState on every
listener first, synchronously. Then it walks the listener list once,
calling each listener’s PreparableReloadListener.reload and handing it a
barrier chained to the previous listener’s returned future (the first
listener’s barrier is chained to the initial task). The barrier’s
PreparableReloadListener.PreparationBarrier.wait does two things: it
posts a task to the main-thread executor that removes the listener from
the set still preparing and completes the all-preparations future when
that set empties, and it returns that future combined with the previous
listener’s. So listener N’s apply runs only after every listener has
reached its barrier and listener N−1 has finished entirely — apply
included — and a listener that never reaches its barrier holds every apply
behind it. The futures are sequenced fail-fast: the first listener to throw
fails the reload as a whole. It does not stop the others — Util.sequenceFailFast
completes the outer future exceptionally and leaves every prepare running;
Util.sequenceFailFastAndCancel, which would cancel them, is not the one
used here. What never happens is the applies.
flowchart TD
SS["PreparableReloadListener.prepareSharedState:<br/>one synchronous pass, before any prepare starts"]:::client
subgraph PREP["prepare, all at once"]
TMp["TextureManager: read every texture"]:::worker
AMp["AtlasManager: stitch every atlas"]:::worker
MMp["ModelManager: load models and block states"]:::worker
ETC["nineteen more listeners"]:::worker
end
ALL["all preparations in: every listener has reached its barrier"]:::client
subgraph APP["apply, in registration order"]
TMa["TextureManager: swap texture contents"]:::client --> AMa["AtlasManager: upload the atlases"]:::client
AMa --> MMa["ModelManager: install the baked models"]:::client
end
SS --> PREP
PREP --> ALL
ALL --> APP
SS -. "the pending sprite futures" .-> MMp
AMp -. "completes them" .-> MMp
Three of the client’s twenty-two listeners, preparing on the worker pool and
applying on the thread that owns the state, with the two rules that order them
drawn once each: nothing in the lower box starts until the gate opens, and
inside it each apply waits on the one above. The dotted pair is the
single exception — AtlasManager.PENDING_STITCH, published in the first
pass and completed during prepare, so the models load while the atlases
stitch instead of queueing behind them.
The listeners registered between the three drawn are elided. Every apply waits on the all-preparations node; each apply also waits on the apply before it; and the dotted pair is the only way one listener’s prepare depends on another’s.
The shared-state channel
PreparableReloadListener.prepareSharedState is a separate first pass for
a reason: it is the one place a listener can publish something for
another listener’s prepare to consume, keyed by a
PreparableReloadListener.StateKey. The game declares exactly one —
AtlasManager.PENDING_STITCH. AtlasManager publishes a future per atlas
there before any prepare starts; ModelManager and ParticleResources
pull the pending sprite futures out of it and wait on them inside their
own prepare, so model loading overlaps atlas stitching rather than queueing
behind it. This is why the model/atlas dependency is not an apply-order
dependency, and why reasoning about it from the registration list gets
the wrong answer. Which thread runs that first pass depends on who started
the reload: the Render thread on the client, but a worker on the server,
because the server’s reload instance is created from inside an
already-async chain.
Prepare never touches live state. That is the whole contract, and it is
one-directional: a listener that reads from the manager in
SimplePreparableReloadListener.apply is reading the new snapshot and
that is fine (LanguageManager does exactly this, in the apply-only shape:
LanguageManager.onResourceManagerReload reads the language files on the
Render thread), while one that mutates
live state in SimplePreparableReloadListener.prepare is racing the
Render thread. Nothing a listener owns is torn down when a reload starts;
the client keeps rendering with the old atlases while the new ones bake.
Apply: registration order
Registration order is apply order. The client registers, in order,
LanguageManager, TextureManager, ShaderManager, SoundManager,
AtlasManager, FontManager, the three colour listeners
(GrassColorReloadListener, FoliageColorReloadListener,
DryFoliageColorReloadListener), ModelManager, EquipmentAssetManager,
PalettedTextureManager, EntityRenderDispatcher,
BlockEntityRenderDispatcher, ParticleResources, GameRenderer,
LevelExtractor, the cloud renderer, GpuWarnlistManager, a
PeriodicNotificationManager, then SplashManager from Gui and
WaypointStyleManager from Hud — twenty-two in all. On the
client ReloadableResourceManager.createReload is called with
Util.backgroundExecutor (named resourceLoad) and Minecraft itself as
the main-thread executor, so apply runs on the Render thread, interleaved
with frames. On the server a /reload calls ReloadableServerResources.loadResources with Util.backgroundExecutor and MinecraftServer, so apply runs on the Server thread; the first load, before any server runs, is handed the thread that waits for it — ServerMain, draining Util.blockUntilDone’s queue, on a dedicated server, and the Render thread when singleplayer opens a world.
The counters SimpleReloadInstance wrapped the executors in are where the
progress bar’s numbers come from: its ReloadInstance.getActualProgress
weighs prepare and apply tasks double and listeners past their barrier
single, and the overlay smooths it.
Finish, or roll back
On the client LoadingOverlay is a poll. It draws the logo from the
vanilla pack outside the reload (via VanillaPackResources.asResourceManager)
and a smoothed bar from ReloadInstance.getActualProgress; a manual reload
fades it in over half a second and it will not fade out until a full second
has passed. Each tick, once ReloadInstance.isDone, it calls
ReloadInstance.checkExceptions and hands the result to its finish
callback. Success runs LevelExtractor.allChanged, which is why every
chunk section rebuilds after F3+T, then ResourceLoadStateTracker.finishReload,
DownloadedPackSource.onReloadSuccess and Minecraft.onResourceLoadFinished.
Failure runs Minecraft.rollbackResourcePacks, which does not find the
offending pack — it deselects every resource pack it can, clears the
options lists, saves, and reloads again, and if vanilla was the only selected pack
it rethrows and crashes instead. That recovery reload bypasses the
one-at-a-time guard, skips the fade, and if it fails the client abandons
recovery: Minecraft.abortResourcePackRecovery drops the overlay,
disconnects any level and returns to the title screen with a failure
toast. ShaderManager is constructed with
Minecraft.triggerResourcePackRecovery for exactly this, so a shader that
fails at runtime rather than at load takes the same road. Throughout,
ResourceLoadStateTracker records what kind of reload this was and with
which packs, so a crash report can say.
On the server there is no overlay; the finish is a continuation on the server thread, and the coda below lists it.
F3+T, end to end
sequenceDiagram
participant KH as KeyboardHandler
participant MC as Minecraft
participant RRM as Reloadable<br/>ResourceManager
participant SRI as SimpleReloadInstance
participant Worker as Worker
participant LO as LoadingOverlay
KH->>MC: reloadResourcePacks, on the F3+T key mapping
MC->>MC: PackRepository.reload, then openAllSelected: keep the selection
MC->>RRM: createReload: close the old manager, build the new snapshot
RRM->>SRI: create: prepareSharedState on every listener, then reload on each
SRI->>Worker: every listener's prepare, all at once
MC->>LO: new, then Gui.setOverlay: the logo and a bar from getActualProgress
Worker->>SRI: each listener reaches its<br/>Preparable<br/>ReloadListener.<br/>PreparationBarrier
SRI->>MC: BlockableEventLoop.execute: each barrier posts a task, the last opens the gate
MC->>MC: apply, one listener per registration slot, between frames
Note over LO: a later tick
LO->>SRI: isDone, then checkExceptions
LO->>MC: rollbackResourcePacks on failure, allChanged on success
The keypress, end to end, with the worker pool drawn as one lane for a whole
pool. The two arrows into Minecraft from below are the reload handing work
back to the Render thread: the barriers’ posted tasks, which open the gate for
the applies, and the overlay’s callback once it finds the instance done.
The key does nothing but ask. KeyboardHandler.handleDebugKeys matches
Options.keyDebugReloadResourcePacks and calls
Minecraft.reloadResourcePacks. If a reload is already showing an overlay,
the request is parked in Minecraft.pendingReload and drained from
Minecraft.runTick, and a second request while one is parked simply
returns the same future. The one path that bypasses the guard is a
recovery reload after a failure. The rest is the five stages above:
PackRepository.reload and PackRepository.openAllSelected on the Render
thread, ReloadableResourceManager.createReload closing the old snapshot
and building the new one, SimpleReloadInstance fanning prepares out to
the resourceLoad pool and marshalling applies back through Minecraft,
and LoadingOverlay.tick polling for the end.
The first reload of the game’s life runs the same way from the Minecraft
constructor, tagged ResourceLoadStateTracker.ReloadReason.INITIAL rather
than manual; a world being opened builds its own first snapshot through
WorldLoader.load, whose WorldLoader.PackConfig.createResourceManager
runs MinecraftServer.configurePackRepository and opens the packs; and a
server-sent pack is just one more RepositorySource, so
DownloadedPackSource triggers an ordinary Minecraft.reloadResourcePacks.
/reload, the same pipeline on the server
| F3+T (client) | /reload (server) | |
|---|---|---|
| who starts it | KeyboardHandler.handleDebugKeys → Minecraft.reloadResourcePacks, parked in Minecraft.pendingReload if an overlay is already up | ReloadCommand, at Commands.LEVEL_GAMEMASTERS → MinecraftServer.reloadResources |
| discovery | PackRepository.reload keeps the current selection; required packs are force-inserted | ReloadCommand.discoverNewPacks runs PackRepository.reload and then selects every available pack not in the world’s disabled list — which is how a datapack dropped into the folder is picked up |
| where the packs are opened | on the Render thread, before the overlay goes up | on the server thread first, one Pack.open per selected id, before any background work starts |
| the manager | a façade swap: ReloadableResourceManager.createReload closes the old MultiPackResourceManager and holds the new one | a fresh MultiPackResourceManager inside a new MinecraftServer.ReloadableResources; the old one is closed only when the new one is installed, and the new one is closed if the reload fails |
| which thread applies, and whether it blocks | the Render thread, between frames; nothing blocks | the Server thread; if /reload is issued from the server thread the method blocks it with BlockableEventLoop.managedBlock until done — /reload stalls the tick |
| how many listeners | twenty-two, in registration order | one — ServerFunctionLibrary (ReloadableServerResources.listeners) |
| what is a registry instead | nothing; the client’s registries arrive over the wire | tags are read before the reload instance by TagLoader.loadTagsForExistingRegistries and applied after it (tags); loot tables, predicates, item modifiers, recipes, advancements and the rest of RegistryDataLoader.RELOADABLE_REGISTRIES load as the RegistryLayer.RELOADABLE layer in ReloadableServerRegistries.reload (identifiers and registries); item component prototypes rebind through BuiltInRegistries.DATA_COMPONENT_INITIALIZERS (data components) |
| when success is reported | at the keypress, in the debug chat line, before the reload has done anything | before the reload runs — the success message is sent first, and a failure arrives later |
| what happens on completion | LevelExtractor.allChanged · ResourceLoadStateTracker.finishReload · DownloadedPackSource.onReloadSuccess · Minecraft.onResourceLoadFinished | close the old MinecraftServer.ReloadableResources · install the new · PackRepository.setSelected · write the new WorldDataConfiguration into level data · ReloadableServerResources.updateComponentsAndStaticRegistryTags · RecipeManager.finalizeRecipeLoading · PlayerList.saveAll · PlayerList.reloadResources — which re-reads every player’s advancements, broadcasts ClientboundUpdateTagsPacket and ClientboundUpdateRecipesPacket, and re-sends every player’s whole recipe book · ServerFunctionManager.replaceLibrary · StructureTemplateManager.onResourceManagerReload |
| what happens on failure | Minecraft.rollbackResourcePacks | the new manager is closed, the old resources stay installed, and the command source is told |
| timing | ProfiledReloadInstance only when the logger is at debug | the same |
Two of those rows are worth a second look. The command tree is rebuilt by
every /reload — a new Commands inside the new
ReloadableServerResources — and nothing re-sends it, so what a connected
client holds is the old tree’s shape. It is less than it sounds, because
both server-side parses read the new dispatcher immediately (Brigadier and
commands). And the
reload is debug-timed only: the “Resource reload finished after N ms” line,
the per-listener timings and the total-blocking-time figure all come from
ProfiledReloadInstance, selected only when the logger is at debug.
Across the wire
A server pushes a pack with ClientboundResourcePackPushPacket (id, URL,
hash, required, prompt) and withdraws one with
ClientboundResourcePackPopPacket, sent by
ServerResourcePackConfigurationTask in the configuration phase and, in a
development build, by ServerPackCommand (/serverpack push|pop) at any time
in play; the
client answers with a ServerboundResourcePackPacket and its
ServerboundResourcePackPacket.Action. Packs are keyed by UUID and stack,
and a server-sent pack pins itself to the top of the selection.
ServerCommonPacketListenerImpl.handleResourcePackResponse disconnects a
client that declines — but the “required” it consults is
MinecraftServer.isResourcePackRequired, a server-wide
server.properties setting, not the flag on the individual pack, so a
declined /serverpack push can disconnect you on a server whose
properties pack is required.
What the client keeps of them is a cache with a hard cap. DownloadQueue
gives every pack a directory of its own under a cache root, runs the
downloads one at a time on a ConsecutiveExecutor over
Util.nonCriticalIoPool, appends every attempt to a JsonEventLog beside
them, and calls DownloadCacheCleaner.vacuumCacheDir at construction to
trim the root to DownloadQueue.MAX_KEPT_PACKS. The cap counts files,
not packs — twenty, newest first, and one file per directory before any
directory’s second — so with one file per pack it is the twenty newest
packs that survive on disk, and the twenty-first is downloaded
again next time.
The system is data-driven by pack.mcmeta (PackMetadataSection, with
min_format / max_format replacing the integer pack_format),
options.txt (Options.resourcePacks), level.dat’s
WorldDataConfiguration (DataPackConfig enabled/disabled plus the
FeatureFlagSet), server.properties for the server-sent pack, and
allowed_symlinks.txt via DirectoryValidator.
Questions players ask
Why does my pack’s animated texture stop animating when another pack
overrides the image? Because the .mcmeta is looked for in the winning
pack or those above it, never below. The override won the image and
brought no metadata.
Why does a datapack I dropped into the folder appear after /reload but
a resource pack I dropped in does not after F3+T? ReloadCommand.discoverNewPacks
selects every newly available pack; PackRepository.reload on the client
only re-discovers and keeps the selection you had.
Why did F3+T turn all my packs off? A listener threw. The rollback does not know which pack did it, so it clears them all and reloads with only the packs it cannot remove: vanilla, and any pack a server sent that was already in use.
Why does /reload freeze the server? When it is issued from the
server thread, MinecraftServer.reloadResources blocks that thread with
BlockableEventLoop.managedBlock until the reload is done.
Why can I disable the vanilla data pack but not the vanilla resource
pack? ClientPackSource marks it required; ServerPacksSource does not.
For a 1.21-era reader. CompositePackResources is
OverlayedPackResources. The vanilla pack is not aPackResourcesany more:VanillaPackResourceshands outFixedPathPackResourceslayers, and its asProvider isVanillaPackResources.asResourceManager.RecipeManagerandServerAdvancementManagerare not reload listeners: recipes and advancements are reloadable registries, andServerFunctionLibraryis the server’s one listener.
Where to look
PackType · PackResources · Pack · PackRepository · PackCompatibility ·
PackFormat · ServerPacksSource · ClientPackSource · BuiltInPackSource ·
FolderRepositorySource · MultiPackResourceManager ·
FallbackResourceManager · ReloadableResourceManager ·
PreparableReloadListener · SimplePreparableReloadListener ·
SimpleJsonResourceReloadListener · SimpleReloadInstance ·
ResourceLoadStateTracker · Minecraft.reloadResourcePacks ·
LoadingOverlay · ReloadCommand · MinecraftServer.reloadResources ·
ReloadableServerResources · WorldLoader · DownloadedPackSource ·
ServerPackManager
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Tags
Verified against Minecraft 26.3 · Part II · A parrot flies down through the canopy looking for a log to perch on, a moment after a data pack put a new block in the logs tag.
A parrot is looking for somewhere to sit. Parrot.ParrotWanderGoal scans
the blocks around it and asks the block state beneath each one whether it is
leaves or in BlockTags.LOGS — a leaves block it recognises by class, a log
only by tag. Nothing in that goal names oak, or spruce, or the
block a data pack added an hour ago; the tutorial toast that tells a new
player to punch a tree asks the same question. A tag is a named set of
registry entries, defined in data-pack JSON and tested against by key, and
it is how data reaches into hard-coded behaviour without the behaviour
naming any specific block. The registries page
ended on a promise: a frozen registry’s contents never change. Its tags
do. Type /reload with a data pack that adds a block to logs and the
parrot perches on it a moment later — with BuiltInRegistries.BLOCK frozen
since before the title screen. Nothing is unfrozen to do it, and no lock is
taken; the swap is safe for a reason that has nothing to do with either.
The cast
| class | what it decides | thread |
|---|---|---|
TagKey | the name: a registry key and an Identifier, interned so equal keys are one object | any |
TagFile · TagEntry | the on-disk shape: a list of entries, each an element or a reference to another tag, and the replace flag | — |
TagLoader | reads every pack’s copy of every file and resolves them in dependency order; what an id resolves to is its TagLoader.ElementLookup | Worker at world load, Server on /reload |
MappedRegistry | the tag half: MappedRegistry.frozenTags, one HolderSet.Named per freeze-time key, and MappedRegistry.allTags, the bound MappedRegistry.TagSet | read from any; written before the freeze by the launching thread, which binds a static registry’s tags to empty, and by a data-pack registry’s load tasks, and after it by whichever thread is applying |
Registry.PendingTags | a loaded table not yet installed, with a Registry.PendingTags.lookup that hands out the new table’s tags, bound only when it is applied | built on a worker at world load, on the server thread for /reload, on the Render thread from the packet; applied on the owning thread |
HolderSet.Named | one tag’s contents, bound at each reload that keeps the tag | — |
Holder.Reference | one element’s own Set of TagKeys — the thing a membership test reads | — |
TagNetworkSerialization | the wire form: tag id to a list of registry ints | Server encodes, client decodes |
A tag is a key and a file
In code a tag is a TagKey: a record of the registry’s ResourceKey and
an Identifier, interned through a weak interner so that TagKey.create
always returns the canonical instance and a membership test is a
set lookup on a canonical key. The keys are declared once, in catalogues that
hold no contents — BlockTags, ItemTags, EntityTypeTags, BiomeTags,
FluidTags and their siblings, twenty-one files in net/minecraft/tags
including DamageTypeTags, EnchantmentTags, StructureTags,
PoiTypeTags, FeatureTags and TimelineTags.
A key that a block and its item share is declared once as a
BlockItemTagId in BlockItemTags and projected into the catalogues that use it:
BlockTags.LOGS is the block half of BlockItemTags.LOGS.
On disk a tag is a TagFile: a list of TagEntry and a replace flag.
Each entry is an element id or a #-prefixed reference to another tag,
with a required flag that defaults to true. The file lives at
data/<namespace>/tags/<registry path>/<name>.json —
Registries.tagsDirPath builds that string in exactly one place, and the
registry path is singular: tags/block, tags/item, tags/entity_type,
tags/worldgen/biome. Vanilla’s own files are written
by the data generator (TagsProvider, TagBuilder), which the running game
never calls, and players reach tags through the #tag syntax of
ResourceOrTagArgument and ResourceOrTagKeyArgument (Part XIII).
ResourceSelectorArgument, beside them, is a glob over ids and takes no
tag at all.
Between the two sits TagLoader, generic over what an id resolves to: a
Holder for a registry, a CommandFunction for function tags. It has two
instance steps. TagLoader.load reads every pack’s copy of every file into
lists of TagLoader.EntryWithSource; TagLoader.build resolves them in
dependency order, TagLoader.tryBuildTag doing one tag at a time through
the TagLoader.ElementLookup the loader was built with. Its output, a map
from each tag to its holders, is resolved but bound to nothing. Nothing owns the
loader: it is called as static functions from WorldLoader,
MinecraftServer.reloadResources, ReloadableServerRegistries, the
registry load tasks and the client’s RegistryDataCollector, with no manager
between them. Function tags are the exception — ServerFunctionLibrary is a real
reload listener and runs its own TagLoader inside it. Everything in
net/minecraft/tags ships in both jars.
Inside a MappedRegistry a tag is two things. MappedRegistry.frozenTags
holds one canonical HolderSet.Named per key for the tags that existed
when the registry froze, and MappedRegistry.allTags is a
MappedRegistry.TagSet, the bound view, which starts out as
MappedRegistry.TagSet.unbound, where every read throws. Beside the
registry, each Holder.Reference carries its own Set of TagKeys, bound
by Holder.Reference.bindTags; that set, not the registry, is what
Holder.Reference.is reads.
The four moments tags are loaded
World load comes first, on the worker pool
(anatomy).
TagLoader.loadTagsForExistingRegistries runs before the reload
listeners, over the RegistryLayer.STATIC layer, and produces a
Registry.PendingTags for every static registry that has at least one tag
that builds. The pending tables are made visible to worldgen and
reloadable-registry loading
through TagLoader.buildUpdatedLookups, and applied, one registry at a
time, in ReloadableServerResources.updateComponentsAndStaticRegistryTags
once every listener has finished. For the whole of a load the old tags are
what Registry.getTags answers and the new table’s names are what the
loading codecs see.
/reload is the same call on the server thread, handed the composite
access of MinecraftServer.registries — so it re-reads and re-applies tags
for the dynamic worldgen registries too, not only the static ones. Of
the loading paths only the reloadable registries re-run — loot tables,
recipes, advancements and the rest — and a worldgen registry keeps its
elements and gets new tags.
The third is a data-pack registry, which loads its tags inside its own load
task rather than through the mechanism above: ResourceManagerRegistryLoadTask
reads them after its elements, with TagLoader.ElementLookup.fromGetters,
and RegistryLoadTask.registerTags binds them under the registry’s write
lock — before the freeze, so there is a lock here and no swap. (The
reloadable registries take this path too — they load through the same load
tasks — so a data pack’s loot-table tags bind like any other’s, and vanilla
ships none.)
The fourth moment is the client’s. One ClientboundUpdateTagsPacket
covering every static and every synced registry is sent by SynchronizeRegistriesTask after
the registry data. In configuration the client merely buffers it —
ClientConfigurationPacketListenerImpl.handleUpdateTags hands it to
RegistryDataCollector.appendTags, and nothing resolves until
configuration finishes. After a server /reload, PlayerList.reloadResources
broadcasts the same packet into the play phase, and
ClientPacketListener.handleUpdateTags handles that one at once.
From JSON to a parrot’s decision
sequenceDiagram
participant WL as WorldLoader
participant TL as TagLoader
participant MR as MappedRegistry
participant RSR as Reloadable<br/>ServerResources
participant Parrot as Parrot
Note over WL,MR: world load, on the worker pool
WL->>TL: loadTagsForExistingRegistries over the STATIC layer
TL->>TL: load: every pack's tags/block/logs.json, lowest first
TL->>TL: build: DependencySorter orders the nested tags, then tryBuildTag
TL->>MR: prepareTagReload: a Registry.PendingTags, which Registry.getTags cannot see yet
Note over WL,RSR: worldgen and reloadable-registry codecs see the new table through Registry.PendingTags.lookup
Note over MR,RSR: the thread driving the load, after the last reload listener has applied
RSR->>MR: Registry.PendingTags.apply: bind, swap, rebind every holder
Note over Parrot: a later server tick, the parrot's wander goal
Parrot->>Parrot: state.is(BlockTags.LOGS) — a set lookup on the holder's own tags
Note over MR,Parrot: no arrow to the registry: the check never reaches it
One tag, from the pack files to the check that uses it. The gap between
MappedRegistry.prepareTagReload and Registry.PendingTags.apply is where
the opening’s riddle lives, a table built that Registry.getTags cannot yet
see, and the last note marks an absence: the parrot’s check touches no
registry at all.
Every pack’s file, lowest first
TagLoader.load asks
the resource system for resource stacks — a
FileToIdConverter.json over the tag directory, listed with
FileToIdConverter.listMatchingResourceStacks — so every copy of
tags/block/logs.json across the enabled packs is visited in priority
order and merged, and a replace in a higher pack discards what the lower
packs contributed to that id. A pack whose file fails to parse is
logged and skipped, never fatal.
Tags of tags resolve in order, and a tag with a hole is dropped whole
The vanilla logs file names no block at all: it is three
tag references, logs_that_burn, crimson_stems and warped_stems, and
logs_that_burn is in turn ten references, oak_logs among them, before
oak_logs finally lists four blocks. TagLoader.build feeds every tag
reference into a DependencySorter and resolves the leaves first. A tag
with any failing entry — a missing required element as much as a
missing required tag reference — is dropped whole, not loaded minus the
entry, and is then absent from Registry.getTags, so neither the network
payload nor a lookup will find it. An optional entry (required: false)
resolves to nothing and the tag still builds: TagEntry.build answers not
required on a miss, whichever lookup it was handed. What the two lookups
differ on is where a required id is looked up, and the difference decides
what a missing one costs. TagLoader.ElementLookup.fromFrozenRegistry, used
for a registry that has already frozen, asks it either way: a name it does
not hold is a miss, and the tag is dropped.
TagLoader.ElementLookup.fromGetters, used by data-pack registries as they
load, sends a required id through the registration lookup, which
manufactures a placeholder Holder.Reference for a name nothing has
registered (MappedRegistry.createRegistrationLookup), and an optional one
through the immutable lookup, which does not. A data-pack registry has
registered all its elements before it reads its tags, so a placeholder there
is never bound: the registry’s freeze fails, with unbound values, and takes
the whole load with it.
Prepared, then applied
This is the swap the opening promised, and it is three steps and no lock.
MappedRegistry.prepareTagReload refuses a registry that is not frozen and
builds the new table, reusing the freeze’s HolderSet.Named objects where it
can; nothing is installed yet, and the Registry.PendingTags.lookup it hands
back answers with the new table’s names, which is what the worldgen and
reloadable-registry codecs are given while they load. Registry.PendingTags.apply is
then three ordered steps: bind each HolderSet.Named, swap the
MappedRegistry.TagSet, then rebind every holder’s tag set in
MappedRegistry.refreshTagsInHolders. Each step replaces an immutable
collection with one reference write, so a reader on another thread —
worldgen on the worker pool during a /reload, the Render thread in
singleplayer — sees a holder’s old tags or its new ones and never a
half-built set; what it can see, for the length of the rebind, is some
holders already new and others still old. Which thread applies depends on the
occasion: at world load the Server thread does not
exist yet, so the apply runs on whichever thread is driving the load — the
launching thread on a dedicated server, through Util.blockUntilDone, and
the Render thread on the client. Only a reload of a running server — /reload, or /datapack enable or /datapack disable — applies on the Server thread.
The client gets integers
TagNetworkSerialization.serializeTagsToNetwork walks
RegistrySynchronization.networkSafeRegistries and writes each tag as a
list of registry ids, dropping any registry whose payload came out empty.
That set is every RegistryLayer.STATIC registry, unconditionally,
concatenated with the synced dynamic ones —
RegistrySynchronization.isNetworkable filters only the second group. Ids
for dynamic registries are meaningful only once both sides have built the
same registry in the same order, which is why SynchronizeRegistriesTask
sends the registry data first. In the configuration phase that ordering is
a send-order constraint rather than a handling one: the client buffers
both packets and resolves everything at the end. The play-phase packet
resolves immediately against the live registry access, and the client then
rebuilds the creative-inventory search tree from the new tags.
Singleplayer skips only what it already has
On the play path
ClientPacketListener.handleUpdateTags always prepares, and skips only the
apply on a memory connection, because the integrated server’s apply
already rebound the registries both halves share. In configuration the
suppression is narrower still: only the non-networkable (static)
registries’ tags are skipped, and the client still binds tags on its own
copies of the remote dynamic registries, copies it then discards for the
server’s own (identifiers and
registries).
The check is a field read
BlockBehaviour.BlockStateBase is a
TypedInstance; TypedInstance.is asks the type holder — for a block,
Block.builtInRegistryHolder, the intrusive holder from
identifiers-and-registries — and
Holder.Reference.is is set-contains on an interned TagKey. No registry
is consulted. Parrot (the perch search), TrunkPlacer (worldgen) and the
client’s PunchTreeTutorialStepInstance all ask this way; FluidState,
Entity and ItemStack go through the same interface.
The other way tags cross the wire
ClientboundUpdateTagsPacket is not the only one. ByteBufCodecs.holderSet
encodes a HolderSet.Named as a marker plus the tag’s Identifier, and
decodes it by looking the tag up in the receiving side’s registry — so any
packet or data component carrying a tag-shaped HolderSet hard-fails on
a client that does not have that tag. That, more than the id numbering,
is why the tags packet must reach the client before play traffic does.
The same idea appears in data: TagKey.hashedCodec, HolderSetCodec and
RegistryCodecs are what turn “#minecraft:logs” in an ordinary JSON field
— a recipe ingredient, a loot condition, a placement predicate — into a
HolderSet without any of those files being tag files.
Questions players ask
What if two tags reference each other? Both are dropped, and both are
logged. DependencySorter.addDependencyIfNotCyclic drops the edge that
would close the cycle, so an order exists and the sort does not hang — but
whichever tag is built first asks for a tag that is not in the new table
yet, gets nothing, and fails as a missing required reference; the second
then asks for the first, which failed, and fails the same way. Two
Couldn’t load tag lines, and neither tag exists.
What happens to a tag a /reload deleted? It keeps its old contents,
and nothing tells you. The tag is absent from the pending map, so apply
neither rebinds nor clears it: anything still holding that HolderSet.Named
will iterate the old list, while HolderSet.Named.contains — which reads
the holder’s refreshed tag set — answers false, and Registry.get for the
key answers empty.
Is picking a random element from a tag deterministic? Yes, per pack
stack. Duplicate entries collapse and file order is preserved —
TagLoader.tryBuildTag collects into an insertion-ordered set — so
iterating a tag, or picking from it with HolderSet.getRandomElement,
gives the same sequence for the same packs.
Which tags does a client never hear about? Those of
RegistryLayer.RELOADABLE-layer registries (loot tables, predicates) and of
non-synced worldgen registries (features, structures) — and on
the receiving side, ids the client’s own registry does not know are dropped
from the payload silently.
For a 1.21-era reader. There is no TagManager and no reload listener for registry tags: loading is static functions on
TagLoader, called fromWorldLoader,MinecraftServer.reloadResources,ReloadableServerRegistries, the registry load tasks and the client’sRegistryDataCollector, and only function tags still go through a real listener. The directory is singular and has no plural fallback — tags/block, not tags/blocks — becauseRegistries.tagsDirPathbuilds that string in one place and accepts nothing else.
Where to look
TagKey · BlockTags · TagFile · TagLoader · Registry.PendingTags ·
MappedRegistry (the tag half) · HolderSet.Named ·
ReloadableServerResources · TagNetworkSerialization ·
Parrot.ParrotWanderGoal
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Data components
Verified against Minecraft 26.3 · Part II · A player types
/give @s diamond_sword[enchantments={sharpness:3}], and later rolls Sharpness onto a plain sword at the enchanting table: what the square brackets are, and how the client finds out.
A player types /give @s diamond_sword[enchantments={sharpness:3}]. The
part in square brackets is a patch: one keyed, typed value laid over
the sword. A data component is exactly that — one typed, keyed piece of
data attached to an item stack: its damage, its enchantments, its lore, the
food it is, the armour slot it goes in. The item type supplies a
prototype map of components; a stack carries only a patch against that
prototype. Everything that used to be “the NBT on an item” is a component
with a codec, and most of what used to be a subclass of Item carrying
behaviour (a sword, a piece of armour) is now a kit of components on a
plain Item. The surprise is where the prototype comes from. It is not
built in the item’s constructor: Item.Properties only records an
initializer, and the map is built again on every reload with the world’s
registries in hand — DataComponentInitializers.build on the reload
worker, Holder.Reference.bindComponents on the owning thread. That is
why a data pack can change what a jukebox plays without touching the item,
and why a stack cannot even be decoded before the first reload:
Item.CODEC_WITH_BOUND_COMPONENTS guards on Holder.areComponentsBound.
The cast
| class | what it decides | thread |
|---|---|---|
DataComponentType | the key: which codec writes the value to disk, which to the wire, and whether it is saved at all | static, registered at bootstrap |
DataComponentMap | the map interface; a prototype is an immutable, identity-keyed one | any; a prototype is never mutated |
PatchedDataComponentMap | what an ItemStack owns: a shared prototype, a patch, and whether the patch is still someone else’s | whichever thread owns the stack |
DataComponentPatch | the serialisable form of the patch: additions and removals | Netty for packets; server and workers for saves |
DataComponentInitializers | builds every registry element’s prototype, from the recorded initializers, once the registries exist | the reload worker on the server; the Render thread on the client |
Holder.Reference | holds the bound prototype for one registry element, and throws until it has one | bound by the thread driving a world load, and by the server thread on /reload; on the client at the end of configuration |
ItemStack | the writes (ItemStack.set, ItemStack.update, ItemStack.remove, ItemStack.copyFrom) and the reads it inherits from DataComponentHolder | whichever thread owns the stack |
DataComponentLookup | the reverse index: which elements of a registry carry this component value | built at freeze, populated lazily |
Everything here ships in both jars; in the trace below only
ClientPacketListener and RegistryDataCollector are client-only.
The shape of a stack
classDiagram
class Item {
Holder.Reference builtInRegistryHolder
}
class ItemStack {
PatchedDataComponentMap components
}
class PatchedDataComponentMap {
DataComponentMap prototype
Map patch
boolean copyOnWrite
}
class DataComponentPatch {
added values
removed keys
}
class DataComponentType {
a persistent codec
a network codec
or only the latter
}
ItemStack *-- PatchedDataComponentMap
PatchedDataComponentMap ..> Item : many stacks, one prototype
PatchedDataComponentMap --> DataComponentPatch : asPatch
DataComponentPatch --> PatchedDataComponentMap : fromPatch, given a prototype
DataComponentType <.. Item : keys the prototype
DataComponentType <.. DataComponentPatch : keys the patch
What a stack holds and what it only points at: a stack owns its
PatchedDataComponentMap and that map owns the patch, while the dotted line to
Item points at a prototype every diamond sword made between two reloads
shares and none can write. DataComponentPatch is the form that leaves, on
disk and on the wire, and PatchedDataComponentMap.fromPatch is how it comes
back.
The item’s prototype is built by DataComponentInitializers and bound onto
the item’s Holder.Reference, which
a section below is about. A
stack points at that shared prototype and owns only a patch over it, in which
a removal is a marker value, Removed.INSTANCE. The patch’s
serialisable twin is a DataComponentPatch, and the key of every entry in
all of them is a DataComponentType. The rest of the page is a tour of
those objects, each grounded in one small trace: Sharpness III arriving on
a sword at the enchanting table.
The key: DataComponentType
A type is built by DataComponentType.Builder. DataComponentType.Builder.persistent
gives the disk and JSON codec; DataComponentType.Builder.networkSynchronized
a hand-written wire codec. A type with no persistent codec is transient
(DataComponentType.isTransient): never saved, only sent.
DataComponentType.Builder.networkSynchronized is not a gate — without it,
DataComponentType.Builder.build derives a wire codec from the persistent
codec (NBT over the wire), and with neither it throws. The only real switch
is transient-versus-persistent, and it gates saving. Three types exist
only on the wire: DataComponents.CREATIVE_SLOT_LOCK,
DataComponents.ADDITIONAL_TRADE_COST, DataComponents.MAP_POST_PROCESSING.
122 vanilla types are registered in DataComponents into
BuiltInRegistries.DATA_COMPONENT_TYPE; 30 of them have slash-shaped ids
(villager/variant and its siblings). The catalogue is
reference/components. Component types
are code, in a registry data packs cannot extend; what data packs reach is
the prototype (below) and CustomData, which carries arbitrary NBT for
them to use.
Two more flags live on the builder. DataComponentType.Builder.cacheEncoding
routes a type’s Codec encodes — disk and JSON, not the wire — through EncoderCache (DataComponents.ENCODER_CACHE).
DataComponentType.Builder.ignoreSwapAnimation is set on exactly one type,
DataComponents.DAMAGE, and the first-person hand that reads it belongs to
items and stacks. Underneath, DataComponentType.PERSISTENT_CODEC
and DataComponentType.VALUE_MAP_CODEC are the shared dispatch machinery
that DataComponentMap.CODEC and the exact predicate are built on.
The maps
A prototype DataComponentMap is immutable and identity-keyed;
DataComponentMap.Builder builds one and can carry a DataComponentMap.Builder.addValidator, which is
where the prototype-time structural rule lives (below). DataComponentMap.EMPTY
is the map every registry element gets when nothing declared one.
DataComponentMap.composite is dead API: it exists, and nothing in 26.3
calls it; the layering that actually happens is PatchedDataComponentMap’s
prototype-plus-patch.
PatchedDataComponentMap is the map an ItemStack owns: a
PatchedDataComponentMap.prototype shared with every stack of that item made
since the last reload, a PatchedDataComponentMap.patch in which the marker
Removed.INSTANCE means removed from the prototype, and a
PatchedDataComponentMap.copyOnWrite flag. It sanitises on every write: PatchedDataComponentMap.set stores
nothing when the value equals the prototype’s, and
PatchedDataComponentMap.remove stores a removal marker only if the
prototype had the key. In the trace, the sword’s prototype carries an
empty ItemEnchantments (every item’s does, through
DataComponents.COMMON_ITEM_COMPONENTS), so Sharpness III differs from the
prototype and the patch gains one entry. Set the enchantments back to empty
and the entry vanishes rather than becoming an explicit default.
The first write pays for the copy. PatchedDataComponentMap.ensureMapOwnership
clones the backing map only when PatchedDataComponentMap.copyOnWrite is
set; ItemStack.copy, PatchedDataComponentMap.asPatch and
ItemStack.transmuteCopy can all alias the same map and set the flag, so
copying a stack is O(1) until someone writes. ItemStack.applyComponentsAndValidate
relies on that: it snapshots with PatchedDataComponentMap.asPatch,
applies, runs ItemStack.validateStrict, and on failure
PatchedDataComponentMap.restorePatch.
Equality is prototype plus patch. ItemStack.isSameItemSameComponents
bottoms out in PatchedDataComponentMap.equals. Because setting a value
equal to the prototype default removes it from the patch, two stacks that
reached the default by different routes compare equal.
The patch, on the wire and on disk
DataComponentPatch is the serialisable form: additions and removals.
DataComponentPatch.CODEC writes removals as !minecraft:foo;
DataComponentPatch.STREAM_CODEC writes two counts then the entries;
DataComponentPatch.DELIMITED_STREAM_CODEC is the length-prefixed variant
for untrusted input; and DataComponentPatch.split is the added/removed
decomposition that the hashing and block-entity paths are both built on.
TypedDataComponent — a type with its value — has its own “type id then
value” stream codec, distinct from the patch encoding, for the places one
value travels alone.
The wire patch never contains defaults — for a real stack. Because
PatchedDataComponentMap sanitises on every write, a value equal to the
prototype’s is dropped rather than sent. ItemStackTemplate is the
exception, and items and
stacks
says why.
On the network the patch travels inside every ItemStack in
ClientboundContainerSetSlotPacket, ClientboundContainerSetContentPacket,
ClientboundSetCursorItemPacket, ClientboundSetPlayerInventoryPacket and
ClientboundSetEquipmentPacket; the client answers ordinary clicks with
hashes, not stacks, and the creative slot alone sends a full, re-validated
stack — codecs, NBT and JSON owns the four
serialisations of a stack, and containers and menus
owns what the server does with a hash. On disk an item is saved as a
patch (ItemStack.MAP_CODEC’s “components”, with transient types
silently dropped), but a block entity as a full DataComponentMap — the
two are not symmetric.
The prototype, and why it is built at reload
A prototype is recorded once and built many times, and the two happen
far apart in the program’s life. The recording is at class-init, long
before a world exists.
Bootstrap.bootStrap has loaded the Items class before
BuiltInRegistries.bootStrap runs — the dispenser and cauldron bootstraps
name items — and its static initialisers each build an Item, so every item
in the game exists, and no item has components. An Item constructor registers
its initializer in BuiltInRegistries.DATA_COMPONENT_INITIALIZERS and nothing
more. Item.Properties.component
and its convenience methods (Item.Properties.durability,
Item.Properties.food, Item.Properties.equippable, Item.Properties.sword,
…) only record a DataComponentInitializers.Initializer; nothing is a
map yet. Item.Properties.delayedComponent and
Item.Properties.delayedHolderComponent are the reason it must be
deferred: they name registry entries that do not exist until a world’s
registries do — an item’s DataComponents.JUKEBOX_PLAYABLE or
DataComponents.DAMAGE_RESISTANT names an entry a data pack can change.
They are also rare. Between them the two methods have twenty-six call
sites in the entire game, all inside Item and Items: fire resistance
resolving DamageTypeTags.IS_FIRE, the banner patterns, the goat horn, the
jukebox songs, the egg variants, the spear’s damage type, the axe, shovel and
hoe’s block transformers, the pottery sherds, the trim materials and a handful
more. Everything else is baked
literal. Item.Properties.repairable is the near miss that shows where the
line runs — it takes a tag, which sounds like context, and is still eager,
because it asks BuiltInRegistries.acquireBootstrapRegistrationLookup at
class-init and stores an unresolved HolderSet instead of waiting for one.
So the deferral exists for twenty-six call sites, and everything else is
deferred along with them because the map is built in one pass.
The maps are built with full registry context — tags, damage types,
jukebox songs resolvable — by DataComponentInitializers.build, reached from
ReloadableServerResources.loadResources on the reload worker on the server, and installed with
Holder.Reference.bindComponents on the owning thread: on the server in
ReloadableServerResources.updateComponentsAndStaticRegistryTags (by the
thread driving a world load, and by the server thread on /reload), on the
client in
RegistryDataCollector at the end of configuration. A /reload therefore
rebinds every item’s prototype. Every registry element gets a component
map (DataComponentMap.EMPTY if it had no initializer), so EntityType
holders have one too — with one asymmetry, and it runs the way round you
would not guess. ClientConfigurationPacketListenerImpl passes
Connection.isMemoryConnection into RegistryDataCollector.collectGameRegistries,
which negates it, so a singleplayer client binds only the registries
RegistrySynchronization.isNetworkable accepts and a multiplayer client
binds every one. Singleplayer can skip the rest because it shares
BuiltInRegistries with the integrated server, whose own apply has already
bound them.
Before binding, reading a registry element’s components throws, and the
failure is a null-check, not a friendly error: Item.components merely
delegates, the throw comes from Holder.Reference.components, and the
non-throwing question is Holder.areComponentsBound.
Item.CODEC_WITH_BOUND_COMPONENTS guards on it and refuses to decode a
stack until then.
Eleven entries in DataComponents.COMMON_ITEM_COMPONENTS make the map
every item’s prototype starts from. Notably it puts an empty ItemEnchantments
on every item, which is what ItemStack.isEnchantable depends on: it gates
first on DataComponents.ENCHANTABLE being present, and then on
DataComponents.ENCHANTMENTS being present and empty.
A prototype can carry a validator. DataComponentMap.Builder.addValidator
is where a structural rule is installed, and Item.Properties installs one:
an item may not be both damageable and stackable. The stack-time twin of that
rule, and the rest of what ItemStack.validateStrict refuses, belongs to
items and stacks.
The reverse index: DataComponentLookup
Every frozen MappedRegistry builds one (Registry.componentLookup): a
lazily-populated reverse index answering “which elements carry this
component value?”, which is how the game finds the spawn egg for an entity
type or the item for a dye colour. It scans the bound prototypes the holders
carry the first time a component type is asked for and keeps what it found,
so before the first reload it throws, and a /reload that changes a
prototype leaves its old answer in place.
The readers and the predicates
DataComponentGetter reads one component; DataComponentHolder reads one
and has a map, and is implemented only by ItemStack, which is where
DataComponentHolder.get, DataComponentHolder.getOrDefault and
DataComponentHolder.has come from. ItemInstance is the read-only face
over ItemStack and ItemStackTemplate that predicates take
(items and
stacks). Beyond item behaviour, the callers are
loot functions (CopyComponentsFunction) and the /give, /item and
/loot commands — which is where the square brackets this page opened on are
finally turned into a patch: ItemParser reads the text component by
component through each DataComponentType’s own codec
(codecs, NBT and JSON),
and what it produces is the same DataComponentPatch the wire and the disk
carry.
The predicates come in two strengths. DataComponentExactPredicate requires
every listed component to equal. The partial DataComponentPredicate
family under core/component/predicates — DataComponentPredicates, 15
kinds, in their own BuiltInRegistries.DATA_COMPONENT_PREDICATE_TYPE
registry — matches a shape rather than a value, and DataComponentMatchers
joins the two for ItemPredicate.
The values, by package
| package | value types |
|---|---|
world/item/component | Consumable, Tool, Weapon, BlocksAttacks, ItemLore, CustomData, TooltipDisplay, ItemContainerContents, BundleContents, TypedEntityData … |
world/item/equipment | Equippable |
world/item/enchantment | ItemEnchantments, Enchantable, Repairable |
Most combat has left the Item subclasses. Weapon, BlocksAttacks,
KineticWeapon, PiercingWeapon, AttackRange, SwingAnimation and
Tool are components; Item.Properties.sword, Item.Properties.spear and
Item.Properties.humanoidArmor build whole kits, and SwordItem is gone;
MaceItem, BowItem, CrossbowItem and TridentItem are among the few that
still carry their own.
The tools that act on a block went the same way: stripping, path-making
and tilling are DataComponents.BLOCK_TRANSFORMER, which
Item.Properties.axe, Item.Properties.shovel and Item.Properties.hoe
point at BlockTransformers.AXE, BlockTransformers.SHOVEL and
BlockTransformers.HOE, and which Item.useOn runs for an item that
carries it.
Sharpness onto a sword, and how the client is told
sequenceDiagram
box transparent the server
participant EM as EnchantmentMenu
participant IStack as ItemStack
participant PDM as PatchedData<br/>ComponentMap
end
box transparent the client
participant CPL as ClientPacketListener
end
Note over EM: the server thread, the button-click packet has arrived
EM->>IStack: enchant, once per EnchantmentInstance
Note over EM,IStack: a book goes through ItemStack.transmuteCopy first: the same patch, a new prototype
IStack->>IStack: EnchantmentHelper.<br/>updateEnchantments, then set
IStack->>PDM: set: ensureMapOwnership clones the shared map, then patch.put
Note over PDM: the patch is now one entry: enchantments to sharpness 3
EM->>EM: broadcastChanges, still inside the handler — this slot does not match
EM->>CPL: ClientboundContainerSetSlotPacket: count, item id, the patch
Note over CPL: decoded on Netty, the patch laid over the client's own prototype
CPL->>CPL: handleContainerSetSlot, then the menu's setItem
One write on the server and one slot packet for the sword, and what crosses is the diff alone. The client never receives the prototype: it rebuilds the stack by laying the patch over the copy it already has, which is why a component the server never changed costs nothing to send.
The menu owns the mutation. EnchantmentMenu.clickMenuButton runs
under ContainerLevelAccess.execute on the server thread and calls
ItemStack.enchant once per chosen EnchantmentInstance. (A book takes one
step more first: ItemStack.transmuteCopy builds a new stack carrying the
same patch over the enchanted book’s prototype.) ItemStack.enchant hands the edit to
EnchantmentHelper.updateEnchantments, which reads the current
ItemEnchantments, edits a mutable copy and writes the immutable result
back with ItemStack.set — under DataComponents.STORED_ENCHANTMENTS for
an Items.ENCHANTED_BOOK and DataComponents.ENCHANTMENTS for everything
else, which is the one place the book branch reaches past the transmute; the enchanting rules, the lapis, the seed and
/enchant are Part VII’s. What matters here is
that enchantments are a value, not a list on the stack: one component, one
write.
One write, one patch entry. ItemStack.set is PatchedDataComponentMap.set.
The map the sword owned was shared with whatever it was copied from, so
PatchedDataComponentMap.ensureMapOwnership clones it now; the new
ItemEnchantments differs from the prototype’s empty one, so the patch
gains its single entry. A book that was transmuted a moment earlier carries
the same patch over a different prototype, which is the whole meaning of
ItemStack.transmuteCopy.
The menu compares, and here it does not wait for the tick. A menu-button
click, like an ordinary click, is answered inside the handler that accepted
it (containers and
menus),
so the correction leaves at once. The
sword’s slot no longer matches what the client was last told, so
ClientboundContainerSetSlotPacket goes out, and only the patch crosses: ItemStack.OPTIONAL_STREAM_CODEC writes the count,
Item.STREAM_CODEC (a registry id) and the patch. The client answers later
clicks with a HashedStack of CRC32C checksums rather than stacks
(codecs, NBT and JSON for the hashing,
containers and menus for the click
protocol).
The client rebuilds against its own prototype. The decoder constructs
ItemStack from holder, count and patch, and that constructor is
PatchedDataComponentMap.fromPatch against the client’s own bound
prototype — the map RegistryDataCollector bound at the end of
configuration, or in singleplayer the server’s own. That is the reason components must be bound on the client
before the play phase: an unbound prototype would throw in a packet decoder
on a Netty thread. ClientPacketListener.handleContainerSetSlot then hands
the stack to AbstractContainerMenu.setItem, and the Sharpness line in the
tooltip, when the sword is hovered, is ItemEnchantments.addToTooltip reading
the same component.
Components on things that are not items
Block entities, both directions. Placing runs
BlockEntity.applyComponentsFromItemStack, which hands subclasses a
recording DataComponentGetter: whatever BlockEntity.applyImplicitComponents
reads is forgotten from the patch (DataComponentPatch.forget) and only the
leftovers persist as opaque BlockEntity.components. Two types are
pre-seeded into that forget set regardless of whether anything reads them
— DataComponents.BLOCK_ENTITY_DATA and DataComponents.BLOCK_STATE — and
only the added half of the resulting patch is kept, so removals are
discarded. Breaking or picking runs the reverse,
BlockEntity.collectComponents over BlockEntity.collectImplicitComponents,
with BlockEntity.removeComponentsFromTag de-duplicating what was
promoted; BlockItem.setBlockEntityData is the write path for the opaque
blob.
Entities, a view with a narrow write path. Entity implements DataComponentGetter with no
patch of its own: Entity.get answers DataComponents.CUSTOM_NAME and
DataComponents.CUSTOM_DATA by hand, lets subclass overrides (Sheep,
Wolf, Villager …) answer the variant-shaped types, and otherwise falls
through to its EntityType holder’s bound prototype — the same map every
registry element gets. Entity.applyComponentsFromItemStack is the write
path, and it is not only spawn eggs: any item-to-entity spawn, an arrow
picking up its stack and a lingering potion’s cloud all take it.
Where to look
DataComponentType · DataComponents · DataComponentMap ·
PatchedDataComponentMap · DataComponentPatch · DataComponentInitializers ·
Holder.Reference · DataComponentLookup · Item.Properties · ItemStack ·
ItemInstance · ItemStackTemplate · EnchantmentMenu ·
AbstractContainerMenu · BlockEntity · Entity (the getter half)
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Text components
Verified against Minecraft 26.3 · Part II · A player dies: the message that names the killer is built on the server, crosses the wire as a translation key, and is worded on the client.
An arrow lands and a player drops. A line appears in everyone’s chat, and
on the victim’s screen the death screen says who did it. The server built
that message on its own thread, inside CombatTracker.getDeathMessage,
and sent it twice: once in ClientboundPlayerCombatKillPacket to the
victim, once as system chat to everybody. But the server never wrote the
sentence. What it built was a Component whose contents are a translation
key — death.attack.arrow — and two arguments, the victim’s name and the
killer’s, each of them another Component carrying a hover card, and a
player’s a click action too. The packet crosses the wire as NBT. On the client the packet
is decoded, handed to a DeathScreen, and still nobody has read the key.
The client receives the death message before it knows what it says.
The death screen’s words are chosen on the first frame that draws it, when
TranslatableContents asks the client’s own Language for the template
behind the key, so two players watching the same death read two
different sentences from one packet — and a dedicated server, which logs the
message too, only ever reads it in English.
The cast
| class | what it decides | thread |
|---|---|---|
Component | the interface: contents, style, siblings, and a walk over them in logical order | built wherever text is made — a server tick, a command, the Render thread |
MutableComponent | the only implementation; MutableComponent.append and MutableComponent.withStyle are how a tree is built | as above |
ComponentContents · TranslatableContents | what a node says: seven kinds, of which the translatable kind is the one that waits for a Language | worded on whichever thread first visits it |
Style | the eleven inheritable fields, immutable, Style.EMPTY the shared blank | any |
ComponentSerialization | the one recursive codec, and the NBT stream codecs built over it | Netty, in PacketEncoder and PacketDecoder; Server or Render for data on disk |
ComponentUtils | ComponentUtils.resolve: selectors, scores and NBT paths into text, against a ResolutionContext | Server, for commands and for the text of a sign, a text display, a written book or a set_name loot function; the client, for a server list’s MOTD |
Language | a key to a template; the client swaps the instance on every resource reload | whichever thread words a message reads it; Language.inject swaps it |
ClickEvent | what a click on styled text may do, and the one action a server may not send | Render thread, when the click lands |
A component is three things
classDiagram
class Component {
<<interface>>
getStyle
getContents
getSiblings
getVisualOrderText
}
class MutableComponent {
ComponentContents contents
Style style
List siblings
}
class Style {
eleven nullable fields
null means inherit
}
Component <|.. MutableComponent : the only implementation
MutableComponent *-- ComponentContents : one of seven kinds
MutableComponent *-- Style
MutableComponent o-- Component : each sibling is one
The triple, and the loop that makes it a tree: a component holds one
ComponentContents, one Style and a list whose members are components
again, the hollow diamond where the recursion is. Nothing here holds an inherited style —
Style.applyTo merges a child’s over its parent’s during the walk, and the
child records nothing.
Component is an interface with four abstract members —
Component.getStyle, Component.getContents, Component.getSiblings and
Component.getVisualOrderText, the last of which is why MutableComponent
caches a laid-out form — and exactly one implementation,
MutableComponent. A component is a triple: one ComponentContents, one
Style, and an ordered list of sibling components. Style inheritance
happens during traversal, not in storage: Component.visit applies a
node’s own style over its parent’s with Style.applyTo and hands the
result down to the contents and then to each sibling in turn, so a red
parent with a plain child draws the child red, and nothing in the child
records it. The mutability is in the name. Component.literal,
Component.translatable and the other factories return a
MutableComponent, and a tree is built by MutableComponent.append and
MutableComponent.withStyle, which replace the style field with the Style
the change produces. Component.copy is a shallow copy — the sibling list is
new, the siblings are shared.
The walk is the only way to read one. Component.getString visits every
node and concatenates what it says; Component.getString with a limit stops
at that many characters. Everything from the walk onwards — the
codepoint stream, bidirectional reordering, glyphs — belongs to
text and fonts.
The seven kinds of contents
Each kind has a static factory on Component:
| kind | class | made by |
|---|---|---|
| text | PlainTextContents, with PlainTextContents.LiteralContents | Component.literal |
| translatable | TranslatableContents | Component.translatable |
| keybind | KeybindContents | Component.keybind |
| score | ScoreContents | Component.score |
| selector | SelectorContents | Component.selector |
| nbt | NbtContents | Component.nbt |
| object | ObjectContents | Component.object |
What each kind says, and when
Text says its string and nothing more; the empty string is the shared
PlainTextContents.EMPTY, which is what Component.empty and
CommonComponents.EMPTY hold.
Translatable is a key, an optional fallback and an array of
arguments. An argument is a number, a boolean, a string or another
Component — TranslatableContents.isAllowedPrimitiveArgument is the
test, and Component.translatableEscape turns anything else into its
string form before it can reach the codec. The template the key names is
read by TranslatableContents.decompose, which accepts only %s, %n$s
and %%: any other specifier is a TranslatableFormatException, and the
component then shows the raw template string instead. The decomposition is
cached against the identity of the Language that produced it
(TranslatableContents.decomposedWith), so a language switch re-words every
component the next time it is visited, without anything being re-sent.
Keybind names a key binding and asks KeybindResolver.keyResolver for
its current name; the default resolver answers with the name itself, and
the client installs KeyMapping.createNameSupplier in the Minecraft
constructor, so key.jump reads as whatever key is bound on a client and
as key.jump anywhere else.
Score, selector and nbt are the three kinds a server has to
resolve. A score names a holder — an entity selector,
a literal name, or ScoreHolder.WILDCARD for the context’s own entity —
and an objective. A selector holds a compiled EntitySelector and an
optional separator; visited unresolved, it shows the selector’s source
text. An nbt component holds an NbtPathArgument.NbtPath, a separator, and
one of three DataSources — EntityDataSource, BlockDataSource,
StorageDataSource — and either prints what it finds through
TextComponentTagVisitor or, with interpret, parses each match as a
component; interpret and plain are refused together. Visited without
resolution, score and nbt contents say nothing at all.
Object puts a picture inside text. Its ObjectInfo is an
AtlasSprite (an atlas and a sprite, the block atlas by default) or a
PlayerSprite (a ResolvableProfile and whether to draw the hat). When
visited with a style it emits a single placeholder character, U+FFFC, with
the style’s font set to the info’s FontDescription; the font system draws
the sprite where a glyph would go. Visited without style —
Component.getString, a log line — it says its fallback, or
ObjectInfo.defaultFallback: [sprite]
for an atlas sprite, [name head] for a player.
Style, and the click that never crosses
Style holds eleven nullable fields: Style.color (a TextColor),
Style.shadowColor, the five booleans — Style.bold, Style.italic,
Style.underlined, Style.strikethrough, Style.obfuscated —
Style.clickEvent, Style.hoverEvent, Style.insertion and Style.font
(a FontDescription). Null means inherit, which is what makes
Style.applyTo a merge: a field the child sets wins, a field it leaves
null falls through to the parent. A setter returns a new Style only when
the value changes — set bold to what it already is and the same object comes back —
and the codec and the setters collapse a style with nothing left set to the
shared Style.EMPTY, so Style.isEmpty is an identity check. ChatFormatting
is the legacy vocabulary — Style.applyFormat sets one boolean or, for a
colour code, TextColor.fromLegacyFormat, and ChatFormatting.RESET
returns Style.EMPTY outright.
A TextColor is 24 bits, masked on construction, and serialises as one of
its sixteen names or as #RRGGBB; TextColor.parseColor accepts either.
The shadow is different: Style.shadowColor is a 32-bit ARGB integer, and
a null one inherits like every other field; Style.withoutShadow sets it to
Style.NO_SHADOW, zero, which is how a component asks to be drawn flat. Style.font is a FontDescription, which may be a
FontDescription.Resource naming a font file, or one of the two sprite
shapes that ObjectContents uses — it need not name a font at all.
Eight clicks, and the one the server may not send
ClickEvent and HoverEvent are interfaces implemented by nested records
— closed by convention and by their Action dispatch codec, not by the
language. There are eight click actions and three hover actions:
ClickEvent.Action | record | carries | a server may send it |
|---|---|---|---|
ClickEvent.Action.OPEN_URL | ClickEvent.OpenUrl | a URI, through ExtraCodecs.UNTRUSTED_URI | ✓ |
ClickEvent.Action.OPEN_FILE | ClickEvent.OpenFile | a path on the viewer’s disk | no |
ClickEvent.Action.RUN_COMMAND | ClickEvent.RunCommand | a command string | ✓ |
ClickEvent.Action.SUGGEST_COMMAND | ClickEvent.SuggestCommand | a string for the chat box | ✓ |
ClickEvent.Action.SHOW_DIALOG | ClickEvent.ShowDialog | a Holder of a Dialog | ✓ |
ClickEvent.Action.CHANGE_PAGE | ClickEvent.ChangePage | a positive page number | ✓ |
ClickEvent.Action.COPY_TO_CLIPBOARD | ClickEvent.CopyToClipboard | a string | ✓ |
ClickEvent.Action.CUSTOM | ClickEvent.Custom | an Identifier and an optional NBT payload, for servers to define | ✓ |
What keeps ClickEvent.Action.OPEN_FILE out of a server’s hands is
ClickEvent.Action.filterForSerialization, applied as a validation on
ClickEvent.Action.CODEC and therefore biting in both directions and
in every format: a data pack cannot write one either, and the client
cannot encode one it built itself. The private flag behind it is
ClickEvent.Action.allowFromServer. The unfiltered
ClickEvent.Action.UNSAFE_CODEC exists and nothing outside the enum reads
it; an open-file click is something only client code constructs in memory —
Screenshot’s notice, a debug dump’s path in KeyboardHandler, a
profiler result in Minecraft — for its own chat. HoverEvent.Action has the identical machinery and
nothing to filter — all three of its values are allowed:
HoverEvent.ShowText wraps a component, HoverEvent.ShowItem an
ItemStackTemplate, HoverEvent.ShowEntity an
HoverEvent.EntityTooltipInfo of type, UUID and optional name.
Serialisation: one codec, three shapes
Serialisation is one recursive codec, ComponentSerialization.CODEC,
whose shape is a three-way choice: a bare string becomes a literal, a list
becomes its first element with the rest appended, and an object is the
full record. Contents are matched by an explicit type field if one is
present and otherwise by trying each contents codec in turn — which is why
an untyped component still round-trips. Component.tryCollapseToString is
what lets a plain unstyled literal encode as a bare string.
The full record is flat. Style.Serializer.MAP_CODEC is inlined, so the
style’s keys — color, shadow_color, bold, italic, underlined,
strikethrough, obfuscated, click_event, hover_event, insertion,
font — sit beside the contents’ own keys, and the siblings are a
non-empty list under extra. The seven contents codecs are registered in
an ExtraCodecs.LateBoundIdMapper under the names in the table above, and
ComponentSerialization.createLegacyComponentMatcher builds the matcher
that the object and data-source kinds reuse for their own object and
source fields. In JSON and NBT, encoding never writes a type: each
kind is written with its own distinguishing key (text, translate,
keybind, score, selector, nbt, and a sprite’s sprite or
player), and a decoder recognises it by that key. A translatable
argument that decodes to a plain unstyled literal is collapsed back to a
string argument, so the argument list round-trips by meaning rather than
by shape.
On the wire: NBT, and two budgets
On the wire in the configuration and play phases, components travel as NBT,
not JSON:
ComponentSerialization.STREAM_CODEC is built over the NBT ops — it is
ByteBufCodecs.fromCodecWithRegistries, which encodes through NbtOps
with the buffer’s RegistryOps and writes the resulting Tag, and on
decode reads a Tag under an NbtAccounter and parses it back. The
accounter is the difference between the two families:
NbtAccounter.defaultQuota allows two mebibytes at depth 512, and the
trusted variants — ComponentSerialization.TRUSTED_STREAM_CODEC and its
siblings, built with ByteBufCodecs.fromCodecWithRegistriesTrusted over
NbtAccounter.unlimitedHeap — lift the NBT size budget (the depth cap stays), and every clientbound
chat packet uses them
(packets and stream codecs).
So do other texts a server authors: the death packet, entity custom
names through EntityDataSerializers.OPTIONAL_COMPONENT, score displays,
painting titles, command-suggestion tooltips. The budgeted codec is what
the two component-typed data components use — DataComponents.CUSTOM_NAME
and DataComponents.ITEM_NAME are synchronised with it — and those are
the components a creative player’s stack carries serverbound. A third shape,
ComponentSerialization.TRUSTED_CONTEXT_FREE_STREAM_CODEC, needs no
registries and carries texts sent where no registry is bound or needed:
ClientboundDisconnectPacket, the MOTD in
ClientboundServerDataPacket, a resource-pack prompt, server links. And
ComponentSerialization.flatRestrictedCodec caps a component by the length
of its JSON form; WrittenBookContent.CONTENT_CODEC uses it at 32,767 for
a page.
Resolution: what a server does to a component before it sends it
Resolution — turning selectors, scores and NBT paths into text — is
ComponentUtils.resolve against a ResolutionContext, which carries the
command source, a depth limit and a ResolutionContext.LimitBehavior. The
walk copies the tree: ComponentContents.resolve returns a copy by default
and is overridden by the score, selector, nbt and object kinds, and by the
translatable kind, which resolves each component argument; siblings are
resolved after the contents, and a HoverEvent.ShowText inside a style is
resolved too. Past the depth limit — 100 by default —
ResolutionContext.LimitBehavior.STOP_PROCESSING_AND_COPY_REMAINING copies
the rest untouched and ResolutionContext.LimitBehavior.DISCARD_REMAINING
puts CommonComponents.ELLIPSIS in its place. A context with no command
source resolves score, selector and nbt contents to empty. The context also
carries an ObjectInfo validator: ObjectContents.resolve swaps a sprite
ResolutionContext.validate rejects for its fallback, which is how ServerStatusPinger
sanitises a server-list MOTD on the client — depth 16, discard past it,
and no player heads.
Ordinary chat never resolves anything: the content of a
ServerboundChatPacket is a plain string all the way to
Component.literal. Commands are the exception —
MessageArgument.Message.toComponent expands entity selectors inside a
message argument, behind a permission, which is why /say @a names people
and a chat line saying the same thing does not. The resolved text becomes
the message’s unsigned content. The mechanics: MessageArgument.Message.parseText
refuses more than 256 characters and, if the source may use selectors at
all (EntitySelectorParser.allowSelectors), tries every @ as a selector
and keeps the ones that parse as a MessageArgument.Part — an @ that
fails on a missing or unknown selector type is left as ordinary text; at execution the permission is
Permissions.COMMANDS_ENTITY_SELECTORS, and each part becomes
EntitySelector.joinNames of what it finds. A raw component argument —
/tellraw’s — is a ComponentArgument, parsed with the full codec from
SNBT and resolved by ComponentArgument.getResolvedComponent with the
target player as the scoreboard entity.
The death message
sequenceDiagram
box transparent the server
participant SP as ServerPlayer
participant CT as CombatTracker
end
box transparent the client
participant CPL as ClientPacketListener
participant DScr as DeathScreen
participant TrC as Translatable<br/>Contents
participant Language as Language
end
Note over SP,CT: the server tick in which the player dies
SP->>CT: getDeathMessage, if SHOW_DEATH_MESSAGES
CT->>CT: the last CombatEntry's deathMessageType, then<br/>DamageSource.getLocalized<br/>DeathMessage
CT-->>SP: a translatable death.attack.arrow, the two names as arguments
Note over SP: the only wording on this side is Component.getString for the console log
SP->>CPL: ClientboundPlayerCombatKillPacket, encoded to NBT on Netty
Note over CPL: a MutableComponent nobody has read
CPL->>DScr: new DeathScreen with the packet's message
Note over DScr: the next frame, on the Render thread
DScr->>TrC: visit reaches the contents
TrC->>Language: getInstance, then getOrDefault of the key
Language-->>TrC: the template from the client's language stack
TrC->>Language: and again for the killer, if it is a mob
TrC-->>DScr: the victim's name, " was shot by ", the killer's name
Four boundaries in one trace: the server tick that builds it, the Netty
thread that encodes it, the Render thread that stores it, and the frame that
finally words it. The component crosses the wire as a key and two arguments, and
before that frame a Language is asked only for the server’s console log and
for the chat line, which a second packet carries.
Built on the server, in no language
ServerPlayer.die runs on the server thread. If
GameRules.SHOW_DEATH_MESSAGES is on it asks the CombatTracker for the
message; CombatTracker.getDeathMessage takes the last CombatEntry,
reads its DamageType.deathMessageType, and for the default kind hands
to DamageSource.getLocalizedDeathMessage, which picks a key from the
damage type’s DamageType.msgId — death.attack. plus the id, with
.player when the source names no entity but LivingEntity.getKillCredit
still finds one (the last player, or failing that the last mob, to hurt
the victim), with .item when the killer’s held item has a
DataComponents.CUSTOM_NAME — and builds Component.translatable with
the victim’s and the killer’s display names as arguments.
DeathMessageType.FALL_VARIANTS and DeathMessageType.INTENTIONAL_GAME_DESIGN
take two more branches, the second of them attaching a
ClickEvent.OpenUrl to a bracketed link — and the fall branch only when
CombatTracker.getMostSignificantFall found one, so a fall-typed
source with no recorded fall drops back to the ordinary message. Which entry
counts as the significant fall, and the five-block threshold under it, belongs to damage and
death. A fourth
branch comes first: an empty combat log is death.attack.generic. The key
is a line in en_us.json; the tracker never sees the line.
The killer’s name is itself a component, and often a translatable one.
Entity.getDisplayName is PlayerTeam.formatNameForTeam over
Entity.getName — the custom name if there is one, otherwise
Entity.getTypeName, which is EntityType.getDescription, a
Component.translatable of entity.minecraft.zombie — with a
HoverEvent.ShowEntity and the UUID as insertion. Player.getDisplayName
adds a ClickEvent.SuggestCommand of /tell name and the name as
insertion. A team wraps the name in its prefix, suffix and colour. So the
argument list carries components inside a component, styles inside
styles, and a mob killer’s name is a second key the client will look up
after the first.
The packet is sent twice over. ClientboundPlayerCombatKillPacket goes to
the victim, with a PacketSendListener.exceptionallySend fallback for a
send that fails — the case its keys have in mind is a message too large to
encode: the replacement carries death.attack.even_more_magic and hangs the
first 256 characters of the real message off it as a
death.attack.message_too_long hover, so a failed send does not leave the
death screen blank. The same component goes to everyone as system chat —
PlayerList.broadcastSystemMessage, or the team-scoped
PlayerList.broadcastSystemToTeam / PlayerList.broadcastSystemToAllExceptTeam
when the team’s Team.Visibility says so — each as a
ClientboundSystemChatPacket. With the game rule off, the kill packet
still goes, carrying CommonComponents.EMPTY, and nothing is broadcast.
Both packets use ComponentSerialization.TRUSTED_STREAM_CODEC, so what
crosses is a compound with translate and with, the arguments compounds
of their own.
On the ordinary path the server reads the message once, for its log:
PlayerList.broadcastSystemMessage starts with
MinecraftServer.sendSystemMessage, which logs Component.getString, and
the walk visits the translatable contents and asks Language.getInstance.
That is the ordinary path only. A victim on a team whose death-message
visibility is not Team.Visibility.ALWAYS goes through
PlayerList.broadcastSystemToTeam or
PlayerList.broadcastSystemToAllExceptTeam instead, neither of which
logs — and Team.Visibility.NEVER matches neither branch, so only the
victim is told, on the death screen, and the server never reads it.
On a dedicated server that is
Language.DEFAULT_INSTANCE, loaded once from the en_us.json on the
classpath, and nothing on the server ever calls Language.inject; a
singleplayer server shares the JVM’s one Language with the client, so its
log line is in the player’s language. A named mob’s death is logged the same
way from LivingEntity.die. A dedicated server’s console is in English
whatever the players speak.
Worded on the client, on the first frame
On the client, ClientPacketListener.handlePlayerCombatKill hops to the
Render thread and constructs a DeathScreen with
ClientboundPlayerCombatKillPacket.message, or
respawns at once if LocalPlayer.shouldShowDeathScreen is off. Nothing has
read the key. The next frame’s DeathScreen.visitText hands the component
to the text collector, and the walk reaches
TranslatableContents.decompose, which asks the current Language for
the template. That Language is a ClientLanguage, built by
LanguageManager.onResourceManagerReload from ClientLanguage.loadFrom
over a stack of two codes — en_us first, then the selected language if
its pack declares it — reading lang/code.json from every namespace of
every enabled pack, merged in stack order. A key missing from the chosen
language falls to English; a key missing from both is shown as itself, by
Language.getOrDefault, unless the component carried a fallback from
Component.translatableWithFallback. Language.loadFromJson rewrites a
%d or %f specifier to %s as it loads, which is why translators’ number
specifiers do not leave the decomposer showing the raw template. The template’s arguments are
visited in their turn — the killer’s name, if it is entity.minecraft.zombie,
goes back to the same Language — and the sentence exists, in this
client’s language, for the first time. The chat line takes the same lookup
through ChatListener.handleSystemMessage, worded when the packet is handled
rather than on a frame.
What this page does not own
Signing and chat delivery — the PlayerChatMessage, the session, the
Not Secure tag — is chat and signing.
Everything after Component.visit — StringDecomposer,
FormattedCharSequence, wrapping, reordering, glyphs — is
text and fonts. What a Codec is, and how
NBT and JSON differ under one, is
codecs, NBT and JSON.
Questions players ask
Why did my friend’s death message say something different from mine?
Because neither client received a sentence. Both received
death.attack.arrow and two name components, and each client’s
Language supplied its own template when it first worded the message.
Why does a message sometimes show as a raw key like death.attack.foo?
The key is in neither the selected language nor en_us, and the component
has no fallback, so Language.getOrDefault returned the key. A server or
data pack that invents a key without shipping a resource pack for it shows
the key on every client.
Why can a server show me a link but not open a file?
ClickEvent.Action.OPEN_FILE fails ClickEvent.Action.filterForSerialization,
so it cannot be encoded into any packet, data pack or book by either side.
Only client code that constructs the ClickEvent.OpenFile in memory — the
screenshot notice, a debug dump’s path — can present one.
Why does a dedicated server’s console show death messages in English? Its
Language is Language.DEFAULT_INSTANCE, the bundled en_us.json;
Language.inject is called only by the client’s LanguageManager, which is
also why a singleplayer world’s log follows the player’s language.
Where to look
Component · MutableComponent · ComponentContents · PlainTextContents ·
TranslatableContents.decompose · KeybindContents · ScoreContents ·
SelectorContents · NbtContents · ObjectContents · Style.applyTo ·
TextColor · ClickEvent.Action.filterForSerialization · HoverEvent ·
ComponentSerialization.CODEC · ComponentSerialization.STREAM_CODEC ·
ByteBufCodecs.fromCodecWithRegistries · ComponentUtils.resolve ·
ResolutionContext · MessageArgument.Message.toComponent ·
CombatTracker.getDeathMessage · DamageSource.getLocalizedDeathMessage ·
ServerPlayer.die · ClientboundPlayerCombatKillPacket ·
ClientPacketListener.handlePlayerCombatKill · DeathScreen.visitText ·
Language.getOrDefault · ClientLanguage.loadFrom ·
LanguageManager.onResourceManagerReload
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
The data-driven type pattern
Verified against Minecraft 26.3 · Part II · A data pack’s loot table says “type”: “minecraft:set_count”, and the game turns that string into an object the file never names.
A data-pack author writes a chest loot table, gives one entry a modifier
whose type field says minecraft:set_count and whose count is a
range, and drops the file into data/mypack/loot_table/chests/. Nothing in
the pack says SetItemCountFunction. Nothing in the jar says
mypack. When the server reloads, ReloadableServerRegistries.reload
has RegistryDataLoader scan the directory and hand the file to
LootTable.DIRECT_CODEC, and
somewhere inside that codec the string minecraft:set_count is looked up
in BuiltInRegistries.LOOT_FUNCTION_TYPE — a registry that
BuiltInRegistries.bootStrap filled and froze before any world existed — and the
MapCodec it finds there reads the rest of the object. The result is a
SetItemCountFunction, and the first time a player opens that chest,
SetItemCountFunction.run calls ItemStack.setCount on every stack that
entry emits. That field is spelled type here and almost everywhere else the
pattern appears, and the move behind it is the same one: fifty-six registries in
BuiltInRegistries are read this way. It is why type is the most
important key in a data pack — every type the pattern reads is a lookup
in a registry data packs cannot add to, so a pack can compose the game’s
behaviours endlessly and never add a new one.
The cast
| class | what it decides | thread |
|---|---|---|
MapCodec (DataFixerUpper) | how to read one kind’s fields out of a JSON object that also carries a type key; the registry element in the bare spelling | — |
Registry | Registry.byNameCodec: a string to an element, with the error Unknown registry key and the element’s registration lifecycle attached | — |
BuiltInRegistries | the registries of kinds — filled during Bootstrap.bootStrap, frozen, identical on client and server | main thread, at Bootstrap |
ReloadableServerRegistries | the eight registries of RegistryDataLoader.RELOADABLE_REGISTRIES, loot tables among them, rebuilt on every reload, and the six with a LootDataType validated | the background executor |
RegistryOps | the ops that let a codec resolve a Holder to another data-pack element while it decodes | wherever the codec runs |
LootItemFunctions | LootItemFunctions.TYPED_CODEC, the dispatch codec for one instance of the pattern; LootItemFunctions.CODEC, which takes an item modifier’s id, one function or a list of them | — |
SetItemCountFunction | the kind traced below: a condition, a ContextIntProvider, an add flag | Server, when the loot rolls |
Holder | how everything else refers to the loaded element — by key, bound later | — |
The idea, stated once
A codec built by Codec.dispatch reads one field of a JSON object — type
unless the caller names another — decodes it with a codec for the kind,
and asks that kind for a MapCodec to read the remaining fields. The class
that does it is DataFixerUpper’s KeyDispatchCodec — a Codec and a
DynamicOps being the two halves of every read in this part
(codecs, NBT and JSON) —
which is why the
MapCodec a kind supplies is a map codec: it reads a set of fields from
the same object the type key came from, so the file looks flat. When the
kind codec is Registry.byNameCodec over a registry in BuiltInRegistries,
the set of kinds is whatever the jar registered at Bootstrap, and a pack
can reach every one of them by name and none it did not ship. The element
that comes out is then either registered — in a RegistryDataLoader
registry, read with the world like Registries.STRUCTURE or on every
reload like Registries.LOOT_TABLE — or inline, a value inside a
larger element that has no id of its own, such as the PlacementModifier
list in a PlacedFeature. A registered element is referred to everywhere
else by Holder: a RegistryFileCodec reads either an id or the inline
object, and the identifiers page explains how the reference is handed out
before the entry exists (identifiers and registries).
flowchart TD
F["a data-pack file with a type key"] --> D["Codec.dispatch over Registry.byNameCodec"]
D --> K["a built-in registry of kinds, frozen at Bootstrap"]
K --> M["that kind's MapCodec reads the remaining fields"]
M --> O["an object of a class the file never named"]
O --> R["registered by RegistryDataLoader, or inline in another element"]
R --> H["referred to by Holder from other files and from the wire"]
The pattern in one line, and the whole of it turns on the third box: the registry of kinds is built in and frozen, so a pack may write any number of elements and no number of kinds. Only the first box is data; every box after it is the jar’s code.
The pattern has two spellings, and they differ only in what the registry
holds. In the bare spelling the element is the MapCodec:
BuiltInRegistries.LOOT_FUNCTION_TYPE is a Registry of
MapCodec, LootItemFunctions.bootstrap registers
SetItemCountFunction.MAP_CODEC under set_count, and
LootItemFunction.codec is how a live object names its own kind for
encoding. In the type-object spelling the element is a small interface
or record that wraps the codec: BlockPredicateType is an interface
with one method, BlockPredicateType.codec, its constants such as
BlockPredicateType.MATCHING_BLOCKS are registered into
BuiltInRegistries.BLOCK_PREDICATE_TYPE, and BlockPredicate.CODEC
dispatches on BlockPredicate.type. A type object lets a kind carry
something beside its codec — RecipeSerializer, ConsumeEffect.Type and
RecipeDisplay.Type are records of a MapCodec and a StreamCodec, one for
the file and one for the wire — though most carry the codec alone. Feature and
WorldCarver take the bare spelling:
BuiltInRegistries.FEATURE_TYPE and BuiltInRegistries.CARVER_TYPE hold
each kind’s MapCodec, and the element the file describes carries its own
parameters and does the work itself, in Feature.place or
WorldCarver.carve.
Eleven of the instances accept a bare value in place of the object, each
through a Codec.either in front of the dispatch, or a Codec.xor in one
case: IntProviders.CODEC, FloatProviders.CODEC,
ContextIntProviders.DIRECT_CODEC, ContextFloatProviders.DIRECT_CODEC
and LevelBasedValue.CODEC read a plain number as a constant,
DensityFunctions.DIRECT_CODEC reads one as a ConstantFunction, a height
provider reads a bare anchor, BlockStateProvider.DIRECT_CODEC a bare block
state, NbtProviders.CODEC and ScoreboardNameProviders.CODEC a bare
string as the context form, and Permission.CODEC a bare identifier as a
Permission.Atom. Two accept a bare list:
LootItemFunctions.DIRECT_CODEC tries LootItemFunctions.TYPED_CODEC and
falls back to SequenceFunction.INLINE_CODEC, so a JSON array where one
function was expected is a sequence of them, and SlotSources.DIRECT_CODEC
does the same through GroupSlotSource.INLINE_CODEC.
Fifty-six of them
Fifty-six registries in BuiltInRegistries hold a kind’s codec, or a
type that carries one, for a codec to dispatch to from the value of a
field: thirty-six bare and twenty type-object. The dispatch key is type unless
the row says otherwise. The criterion is the codec in the registry, not Registry.byNameCodec
itself: more registries dispatch through it and are not here.
BuiltInRegistries.GAME_RULE spells the registry name as the key of a map
rather than the value of a field, and so does BuiltInRegistries.STAT_TYPE
in a player’s statistics file, though a player predicate dispatches on it by
type. BuiltInRegistries.ENVIRONMENT_ATTRIBUTE and
BuiltInRegistries.DATA_COMPONENT_TYPE are keys nearly everywhere a data
pack meets them, and each also backs a field dispatch or two —
EnvironmentAttributeCheck.MAP_CODEC on attribute, and two client
item-model properties. All four are among the exceptions below.
The bare spelling: the registry holds a MapCodec
| registry | element | key | where the elements live | taught in |
|---|---|---|---|---|
BuiltInRegistries.LOOT_POOL_ENTRY_TYPE | LootPoolEntryContainer | inline in loot tables | loot tables | |
BuiltInRegistries.LOOT_FUNCTION_TYPE | LootItemFunction | Registries.ITEM_MODIFIER (reloadable), and inline in tables, pools and entries | loot tables | |
BuiltInRegistries.LOOT_CONDITION_TYPE | LootItemCondition | Registries.PREDICATE (reloadable), and inline | contexts and predicates | |
BuiltInRegistries.CONTEXT_INT_PROVIDER_TYPE | ContextIntProvider | Registries.CONTEXT_INT_PROVIDER (reloadable), and inline; a bare integer is a constant | contexts and predicates | |
BuiltInRegistries.CONTEXT_FLOAT_PROVIDER_TYPE | ContextFloatProvider | Registries.CONTEXT_FLOAT_PROVIDER (reloadable), and inline; a bare number is a constant | contexts and predicates | |
BuiltInRegistries.LOOT_NBT_PROVIDER_TYPE | NbtProvider | inline in loot functions | contexts and predicates | |
BuiltInRegistries.LOOT_SCORE_PROVIDER_TYPE | ScoreboardNameProvider | inline in the score int provider, ScoreboardValue | contexts and predicates | |
BuiltInRegistries.SLOT_SOURCE_TYPE | SlotSource | Registries.SLOT_SOURCE (reloadable), and inline in SlotLoot entries | contexts and predicates | |
BuiltInRegistries.INT_PROVIDER_TYPE | IntProvider | inline in features and elsewhere; a bare integer is a constant | features and placement | |
BuiltInRegistries.FLOAT_PROVIDER_TYPE | FloatProvider | inline; a bare float is a constant | features and placement | |
BuiltInRegistries.DENSITY_FUNCTION_TYPE | DensityFunction | Registries.DENSITY_FUNCTION, and inline; a bare number is a constant | density functions | |
BuiltInRegistries.MATERIAL_CONDITION_TYPE | MaterialCondition | Registries.MATERIAL_CONDITION, and inline in material rules | terrain | |
BuiltInRegistries.MATERIAL_RULE_TYPE | MaterialRule | Registries.MATERIAL_RULE, and inline | terrain | |
BuiltInRegistries.BIOME_SOURCE | BiomeSource | inline in Registries.LEVEL_STEM | biomes | |
BuiltInRegistries.CHUNK_GENERATOR | ChunkGenerator | inline in Registries.LEVEL_STEM | terrain | |
BuiltInRegistries.FEATURE_TYPE | Feature | Registries.FEATURE, and inline | features and placement | |
BuiltInRegistries.PLACEMENT_MODIFIER_TYPE | PlacementModifier | inline in Registries.PLACED_FEATURE | features and placement | |
BuiltInRegistries.BLOCK_STATE_PROVIDER_TYPE | BlockStateProvider | Registries.BLOCK_STATE_PROVIDER, and inline in features; a bare block state is a SimpleStateProvider | features and placement | |
BuiltInRegistries.CARVER_TYPE | WorldCarver | Registries.CARVER | terrain | |
BuiltInRegistries.STRUCTURE_PLACEMENT | StructurePlacement | inline in Registries.STRUCTURE_SET | structure placement | |
BuiltInRegistries.STRUCTURE_PROCESSOR | StructureProcessor | processor_type | Registries.PROCESSOR_LIST | jigsaw and templates |
BuiltInRegistries.POOL_ALIAS_BINDING_TYPE | PoolAliasBinding | inline in jigsaw structures | jigsaw and templates | |
BuiltInRegistries.ENCHANTMENT_LEVEL_BASED_VALUE_TYPE | LevelBasedValue | inline in Registries.ENCHANTMENT | enchantments | |
BuiltInRegistries.ENCHANTMENT_ENTITY_EFFECT_TYPE | EnchantmentEntityEffect | inline in Registries.ENCHANTMENT | enchantments | |
BuiltInRegistries.ENCHANTMENT_LOCATION_BASED_EFFECT_TYPE | EnchantmentLocationBasedEffect | inline in Registries.ENCHANTMENT | enchantments | |
BuiltInRegistries.ENCHANTMENT_VALUE_EFFECT_TYPE | EnchantmentValueEffect | inline in Registries.ENCHANTMENT | enchantments | |
BuiltInRegistries.ENCHANTMENT_PROVIDER_TYPE | EnchantmentProvider | Registries.ENCHANTMENT_PROVIDER | enchanting | |
BuiltInRegistries.SPAWN_CONDITION_TYPE | SpawnCondition | inline in entity variants, through SpawnPrioritySelectors.CODEC | entity lifecycle | |
BuiltInRegistries.TEST_ENVIRONMENT_DEFINITION_TYPE | TestEnvironmentDefinition | Registries.TEST_ENVIRONMENT | game tests | |
BuiltInRegistries.TEST_INSTANCE_TYPE | GameTestInstance | Registries.TEST_INSTANCE | game tests | |
BuiltInRegistries.DIALOG_TYPE | Dialog | Registries.DIALOG | dialogs | |
BuiltInRegistries.DIALOG_ACTION_TYPE | Action | inline in dialogs | dialogs | |
BuiltInRegistries.DIALOG_BODY_TYPE | DialogBody | inline in dialogs | dialogs | |
BuiltInRegistries.INPUT_CONTROL_TYPE | InputControl | inline in dialogs, as a MapCodec (Codec.dispatchMap) | dialogs | |
BuiltInRegistries.PERMISSION_TYPE | Permission | inline in a permission check | permissions | |
BuiltInRegistries.PERMISSION_CHECK_TYPE | PermissionCheck | only written, by ArgumentUtils into the command-tree report | permissions |
The type-object spelling: the registry holds a type that carries a MapCodec
| registry | type object | element | key | where the elements live | taught in |
|---|---|---|---|---|---|
BuiltInRegistries.HEIGHT_PROVIDER_TYPE | HeightProviderType | HeightProvider | inline in placements; a bare anchor is a constant | features and placement | |
BuiltInRegistries.BLOCK_PREDICATE_TYPE | BlockPredicateType | BlockPredicate | inline in features and placements | features and placement | |
BuiltInRegistries.TRUNK_PLACER_TYPE | TrunkPlacerType | TrunkPlacer | inline in tree features | trees | |
BuiltInRegistries.FOLIAGE_PLACER_TYPE | FoliagePlacerType | FoliagePlacer | inline in tree features | trees | |
BuiltInRegistries.ROOT_PLACER_TYPE | RootPlacerType | RootPlacer | inline in tree features | trees | |
BuiltInRegistries.TREE_DECORATOR_TYPE | TreeDecoratorType | TreeDecorator | inline in tree features | trees | |
BuiltInRegistries.FEATURE_SIZE_TYPE | FeatureSizeType | FeatureSize | inline in tree features | trees | |
BuiltInRegistries.STRUCTURE_TYPE | StructureType | Structure | Registries.STRUCTURE | structure placement | |
BuiltInRegistries.STRUCTURE_POOL_ELEMENT | StructurePoolElementType | StructurePoolElement | element_type | inline in Registries.TEMPLATE_POOL | jigsaw and templates |
BuiltInRegistries.RULE_TEST | RuleTestType | RuleTest | predicate_type | inline in processor lists | jigsaw and templates |
BuiltInRegistries.POS_RULE_TEST | PosRuleTestType | PosRuleTest | predicate_type | inline in processor lists | jigsaw and templates |
BuiltInRegistries.RULE_BLOCK_ENTITY_MODIFIER | RuleBlockEntityModifierType | RuleBlockEntityModifier | inline in processor rules | jigsaw and templates | |
BuiltInRegistries.RECIPE_SERIALIZER | RecipeSerializer | Recipe | Registries.RECIPE (reloadable), which RecipeManager indexes into a RecipeMap | recipes | |
BuiltInRegistries.RECIPE_DISPLAY | RecipeDisplay.Type | RecipeDisplay | inline, mostly on the wire to the recipe book | recipes | |
BuiltInRegistries.SLOT_DISPLAY | SlotDisplay.Type | SlotDisplay | inline in recipe displays | recipes | |
BuiltInRegistries.CONSUME_EFFECT_TYPE | ConsumeEffect.Type | ConsumeEffect | inline in the Consumable and DeathProtection components | using an item | |
BuiltInRegistries.TRIGGER_TYPES | CriterionTrigger | Criterion | trigger, with the fields under conditions (ExtraCodecs.dispatchOptionalValue) | inline in Registries.ADVANCEMENT (reloadable) | advancements |
BuiltInRegistries.PARTICLE_TYPE | ParticleType | ParticleOptions | inline in biome ambient particles (AmbientParticle) and area effect clouds | particles | |
BuiltInRegistries.NUMBER_FORMAT_TYPE | NumberFormatType | NumberFormat | inline in Objective and Score — save data and commands, no pack | scoreboard and data | |
BuiltInRegistries.POSITION_SOURCE_TYPE | PositionSourceType | PositionSource | inline in VibrationParticleOption, the vibration particle’s options, which a pack can write wherever it writes a particle | game events and vibrations |
The first seven rows are the sub-objects of a feature or a placed feature, and they split between two pages: the first two are the placement layer, on features and placement, and the five tree slots are trees, which is one feature’s inside seen whole.
Every registered destination is a RegistryDataLoader registry. Most are
read once, when the world loads; the eight of
RegistryDataLoader.RELOADABLE_REGISTRIES — Registries.LOOT_TABLE,
Registries.ITEM_MODIFIER, Registries.PREDICATE, Registries.RECIPE,
Registries.ADVANCEMENT and three more — are read again on every reload by
ReloadableServerRegistries, and DatapackStructureReport calls them
stable dynamic registries. The elements that reach the client are the ones in
RegistryDataLoader.SYNCHRONIZED_REGISTRIES, re-encoded with the direct
codec or, for thirteen of them, a smaller network codec, which is why BuiltInRegistries must be identical on both
sides: the client runs the same dispatch on the same kinds
(protocol phases).
One instance traced: set_count
sequenceDiagram
participant RSReg as Reloadable<br/>ServerRegistries
participant LT as LootTable
participant LIF as LootItemFunctions
participant BIR as BuiltInRegistries
participant SICF as SetItemCountFunction
participant CBE as ChestBlockEntity
Note over RSReg: a reload, on the background executor
RSReg->>RSReg: reload: RegistryDataLoader.load over the reloadable registries
RSReg->>LT: DIRECT_CODEC parses one loot_table file
LT->>LIF: an entry's modifier: CODEC, DIRECT_CODEC, then TYPED_CODEC reads the type key
LIF->>BIR: LOOT_FUNCTION_TYPE.<br/>byNameCodec looks up minecraft:set_count
BIR-->>LIF: SetItemCountFunction.<br/>MAP_CODEC, out of a frozen registry
LIF->>SICF: MAP_CODEC reads condition, count and add
SICF-->>LT: the function object, held as the entry's modifier
LT-->>RSReg: into a fresh MappedRegistry, the RELOADABLE layer replaced
Note over CBE: a later tick, on the server thread, a player opens the chest
CBE->>RSReg: the key, from RandomizableContainer.unpackLootTable
RSReg-->>CBE: the LootTable, or LootTable.EMPTY for an unknown key
CBE->>LT: fill, then getRandomItems with a CHEST context
LT->>SICF: the entry's decorate wraps its output, every stack passes through apply
SICF->>SICF: the condition passes, run calls ItemStack.setCount
LT-->>CBE: the stacks, shuffled into the chest's slots
One loot function’s whole life, in two stretches: parsed once at reload,
where the registered type supplies the codec and the file only the config,
and run much later, when a chest is filled and every stack its entry emits
passes through SetItemCountFunction.
The reload half. MinecraftServer.reloadResources — and WorldLoader.load
on first start — calls ReloadableServerResources.loadResources, whose first
act is ReloadableServerRegistries.reload on the background executor. It
builds lookups that already carry the updated tags, so a loot
condition can name an item tag while it decodes
(tags), and hands them to
RegistryDataLoader.load with RegistryDataLoader.RELOADABLE_REGISTRIES —
loot tables, predicates, item modifiers and five more, read the way the
world’s registries are rather than by a listener of the
every-JSON-file-in-a-directory shape
(the resource system).
For each, RegistryDataLoader creates a new MappedRegistry, lists the directory with
FileToIdConverter.registry over Registries.elementsDirPath — the
directory is the registry’s path, loot_table — and parses every
file with the registry’s codec through a RegistryOps over
JsonOps.INSTANCE. It collects every error by key and fails the whole
load, so one bad table costs the whole reload: a loot table naming
minecraft:set_cuont makes a /reload fail and keep the data it had, and
stops the world from opening on first start, as a typo in a biome does.
Inside LootTable.DIRECT_CODEC the entry’s modifier is read by
LootItemFunctions.CODEC, which takes an item modifier’s id or the
function itself; the value in question is an object, so
LootItemFunctions.DIRECT_CODEC takes its first branch,
LootItemFunctions.TYPED_CODEC: Registry.byNameCodec on
BuiltInRegistries.LOOT_FUNCTION_TYPE reads minecraft:set_count,
finds SetItemCountFunction.MAP_CODEC, and that codec reads condition
(the LootItemConditionalFunction.commonFields every conditional function
shares, itself dispatched on BuiltInRegistries.LOOT_CONDITION_TYPE),
count through ContextIntProviders.CODEC — a third dispatch, on
BuiltInRegistries.CONTEXT_INT_PROVIDER_TYPE — and the optional add.
At least three built-in registries are consulted to build this one function,
and the file names none of them. A misspelt kind fails at the first of them with
an Unknown registry key error that names the registry of kinds, and the
whole load fails with it. An entry, a pool and a table each hold at most one
modifier, and several functions in one place are one SequenceFunction,
which is what a JSON array in that field reads as. That same
LootItemFunctions.CODEC decodes the modifier field of a pool and
of the table as well as of an entry, which is why one kind name is accepted
in all three places — the three modifiers wrap the output consumer
one inside the other, and a modifier on the table runs last.
After all eight registries are built and frozen,
ReloadableServerRegistries.createAndValidateFullContext replaces the
RegistryLayer.RELOADABLE layer through LayeredRegistryAccess.replaceFrom
and ReloadableServerRegistries.validateLootRegistries runs
LootDataType.runValidation over every element of the six registries that
have a LootDataType:
SetItemCountFunction.validate walks into its count provider, entering any
Registries.CONTEXT_INT_PROVIDER entry it names by id. Validation warns —
problems are logged, the element stays registered — except for a reference
cycle, which is fatal and fails the reload. An element from one of the game’s
own packs registers as Lifecycle.stable, one from any other pack as
Lifecycle.experimental.
The run half. The pack described a function with a string, and the string
is now an object hanging off a pool’s entry. What runs it is the ordinary
machinery of a draw — RandomizableContainer.unpackLootTable asking
MinecraftServer.reloadableRegistries for the table, the pools rolling, the
tiers of modifiers wrapping the output consumer one inside the
other, and SetItemCountFunction.run finally calling ItemStack.setCount with
a number a ContextIntProvider produced (loot
tables). None of that is the pattern;
the pattern ended the moment the object existed.
What does not follow the pattern
Not every registry in BuiltInRegistries whose name ends in type is a
registry of kinds. Two groups account for most of the ones that are not.
A few fall outside them altogether — BuiltInRegistries.TICKET_TYPE,
BuiltInRegistries.MAP_DECORATION_TYPE,
BuiltInRegistries.POINT_OF_INTEREST_TYPE and
BuiltInRegistries.VILLAGER_TYPE are registries of ordinary things whose
names happen to end in type, dispatching nothing, and BuiltInRegistries.ATTRIBUTE_TYPE has a Registry.byNameCodec,
AttributeTypes.CODEC, that nothing in the tree reads; the attribute name
a file actually uses as a key belongs to
BuiltInRegistries.ENVIRONMENT_ATTRIBUTE.
A key, not a kind. BuiltInRegistries.DATA_COMPONENT_TYPE,
BuiltInRegistries.ENCHANTMENT_EFFECT_COMPONENT_TYPE,
BuiltInRegistries.GAME_RULE and BuiltInRegistries.ENVIRONMENT_ATTRIBUTE
each hold objects that carry a codec for
their value, and a file uses them as JSON keys: GameRuleMap.CODEC
and DataComponentPredicate.CODEC are Codec.dispatchedMap, a map whose
key codec is Registry.byNameCodec and whose value codec depends on the
key. There is no type field because the name of the field is the type.
BuiltInRegistries.ENTITY_SUB_PREDICATE_TYPE is the same shape and holds a
plain Codec rather than a MapCodec, and EntityPredicate reads it as a
dispatched map too. BuiltInRegistries.STAT_TYPE is a key in a player’s
statistics file and the type a PlayerPredicate.StatMatcher dispatches on,
and either way its value codec is derived from StatType.getRegistry rather
than stored. BuiltInRegistries.MEMORY_MODULE_TYPE
is a key in a brain’s saved memories, MemoryMap.CODEC, the same way.
A type object with no codec. BuiltInRegistries.RECIPE_TYPE is the
one a modder reaches for and the wrong one: RecipeType.CRAFTING groups
recipes for lookup, and the kind a recipe file names — the field
Recipe.DIRECT_CODEC dispatches on through Recipe.getSerializer — is a
RecipeSerializer, in BuiltInRegistries.RECIPE_SERIALIZER.
BuiltInRegistries.STRUCTURE_PIECE holds StructurePieceType, a loader
from NBT with a StructurePieceSerializationContext, for the pieces of a
started structure saved in the chunk — save data, not a pack.
BuiltInRegistries.ENTITY_TYPE, BuiltInRegistries.BLOCK_ENTITY_TYPE,
BuiltInRegistries.MENU, BuiltInRegistries.SENSOR_TYPE and
BuiltInRegistries.COMMAND_ARGUMENT_TYPE
are registries of type objects that no codec dispatches on: they are
looked up by name and construct or describe things in Java.
For a 1.21-era reader. The loot registries of kinds hold the
MapCodecitself — there is no LootItemFunctionType — and a functions list is one modifier, keyed type; NumberProvider isContextIntProviderandContextFloatProvider. Int and float providers, structure processors, features, carvers, placement modifiers, state providers and structure placements went the same way, and ConfiguredFeature is gone: aFeaturecarries its own configuration. Surface rules areMaterialRules. The rest kept their type objects, and both spellings dispatch identically.
Where to look
Codec.dispatch · KeyDispatchCodec · Registry.byNameCodec ·
BuiltInRegistries · LootItemFunctions.TYPED_CODEC ·
LootItemFunctions.bootstrap · SetItemCountFunction.MAP_CODEC ·
BlockPredicate.CODEC · BlockPredicateType ·
Feature.DIRECT_CODEC · FeatureTypes.bootstrap ·
LootDataType · ReloadableServerRegistries.reload ·
RegistryDataLoader.RELOADABLE_REGISTRIES · RegistryFileCodec ·
RegistryDataLoader.WORLD_REGISTRIES · LootTable.fill ·
LootItemFunction.decorate · SetItemCountFunction.run
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
III · The server
Verified against Minecraft 26.3 · Part III · The program that owns the world: one thread, one loop, twenty times a second, from the command line that starts it to the three different ways it stops.
Everything a player thinks of as the world — the blocks, the mobs, the
weather, the hunger bar — is state owned by one object on one thread, and
this part is that thread doing a full lap. Part I named the Server thread
(anatomy); this is the
first place you watch it work. A player recognises this part by its clock:
nothing in the world happens continuously, and a furnace advances in steps of
a twentieth of a second. The claim the part makes is that almost everything
surprising about a server’s timing is the order of one method — which step
of MinecraftServer.tickChildren runs before which — and a reader who
finishes it can answer when for anything the server does: when a block
change reaches a screen, when a mob stops being ticked, when a chest is on
disk, and what is lost if the process is killed between two of those moments.
That is a small system to know so much about. Counting the four packages
the atlas lists for this part
— net/minecraft/server itself, and server/level, server/players and
server/dedicated under it — the way it counts everything else, it is
95 files and 21,900 lines. Over half of those lines are
server/level’s forty-two files at nearly three hundred lines apiece,
because the objects that own a world are few and enormous. The other landmark
is one file: MinecraftServer, the largest of the ninety-five and most of
this part’s first page and its last two. Part I counts it too, because it is where the
Server thread is made — the atlas lets a class belong to two parts, and this
is the part that watches it work.
The shape of the part
Part III is a line into a loop and out again. Two pages are the loop itself — the server tick and the one step of it that is a whole lecture — and the other three are the beginning, the population and the end.
flowchart TD
Tick["1 · The server tick"]
Level["2 · The level tick"]
Players["3 · Players and sessions"]
Start["4 · Starting a server"]
Death["5 · How a server dies"]
Start -- "the thread, the levels" --> Tick
Tick -- "MinecraftServer.tickChildren" --> Level
Level -- "the packets it wrote" --> Tick
Tick -- "the packet drain, the connection phase" --> Players
Players -- "a join, a disconnect" --> Tick
Tick -- "the loop's finally" --> Death
The five lectures of Part III, numbered to the watch order and wired in the order the program runs them; an arrow is a hand-off at run time, not a dependency between lectures.
The two numbered first are the loop itself. Seven later parts (IV, V, VI, VII, VIII, IX and XIII: every later part on the server’s side of the game except world generation) assume one of those two or the other, which makes them the most load-bearing pair in the book after Anatomy. The beginning and the end are numbered last because there has to be something for them to start and stop.
Before you start
Anatomy, and specifically its two loops figure. This part assumes you know that the Server thread is an event loop as well as a game loop, that a packet is decoded on a Netty thread and handled on this one, and that the client’s frame loop is a separate clock. Part II’s codecs and registries are assumed wherever something is written to disk or sent on the wire, and the resource system is assumed once, by starting a server, which runs its staged load for server data.
Two pages from a later part are assumed, and only one of them has to be read first. Tickets and loading owns what entity-ticking and block-ticking range mean, but the level tick defines both before it uses them, so it keeps until Part IV. Environment attributes and timelines does not keep: the level tick’s first act is to throw that system’s cache away, and that step does not mean much to a reader who has never met it. Watch that page out of order; everything else in Part IV can wait.
Watch in this order
- The server tick — one 50 ms lap: the deadline that moves before the work starts, every packet since last time handled at once, every dimension advanced, and the two writes per client that carry the tick’s own packets. “Can’t keep up!” is not a warning that the server is about to skip ticks — it is the skip.
- The level tick — one step of that lap, which is the whole world changing: weather, scheduled ticks, mob spawns, random ticks, every entity, every block entity. The block changes are broadcast before the entities move, so a change a piston makes reaches you a tick later than one a player’s command makes — with falling sand the exception that sends its own packets, and a console command as late as the piston.
- Players and sessions — a join from the end of the login handshake to a player standing in a world with chunks on the way, and then the four ways that session changes: death, a dimension, a disconnect, and a debug command that sends the player back through the join handshake without dropping the socket. Dying replaces your player object; the Nether does not.
- Starting a server — java -jar server.jar to
the word Done: the EULA, the lock on
session.lock, the packs and registries, the thread, the levels. The step that loads the world’s chunks loads none of them on an ordinary world. - How a server dies — three endings compared:
/stop, a crash in the tick loop, and the watchdog thread that kills a server whose tick runs past its limit. A crash in the tick saves your world. The watchdog does not.
Where the part stops
Almost nothing: 2% of the part’s lines are named on no page in the book,
which after Parts I and VIII is as close to complete as the book gets. What
the five lectures leave out they mostly leave to another part, because a
package is not a subject; the rest is a handful of small classes no lecture
names, ServerEntityGetter the largest. Chunk generation — half of
ChunkMap and everything around it — is Part IV’s, where a chunk rather than
a tick is the thing being followed (chunk
generation). The server half of a
click is Parts V, VII and VIII’s. The machinery beside the loop that commands
and data packs drive — the scoreboard, the function library, the advancement
manager, all living in net/minecraft/server — is Part XIII’s, and the reload beside it is Part II’s.
What is left, and it is the part’s whole subject, is the loop, the levels it
drives, the players in them, and the two ends of the process.
Reference this part uses
Threads — the Server thread, the worker pool and the dedicated server’s five side threads, with who makes each. Game rules — the fourteen these five pages name, out of fifty-nine. Level data and rules — which file remembers what, and how level.dat is written. Packets — everything the tick sends. Diagram lanes — what each lane in the part’s figures stands for.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
The server tick
Verified against Minecraft 26.3 · Part III · One 50 ms tick on the Server thread, from the moment the clock says now to the moment the thread parks again.
Twenty times a second the Server thread wakes, hands every packet that
arrived since it last looked to the code that answers it, advances every
dimension by one step, pushes what each player needs to know onto the wire,
and then spends whatever is left of its 50 ms on work other threads posted
back before parking until the next beat. Everything a player calls the
world — blocks, mobs, weather, the contents of a chest — is computed in
plenty of places and committed in this one, on this one thread. When a
lap runs long the console eventually prints Can’t keep up! Is the server
overloaded?, and that line is not a warning that the server is about to
start missing ticks. MinecraftServer.runServer logs it and advances the
deadline past the whole backlog in the same branch, so the message is
the skip; and because that branch will not fire again for a further ten
seconds and a hundred ticks of the server’s own scheduled time, a server that
complained recently keeps running behind rather than skipping at all. And the
ticks it does skip are simply gone — nothing runs them later, so game time
ends up that many ticks younger than the wall clock and stays there.
The cast
| class | what it decides | thread |
|---|---|---|
MinecraftServer | the loop, the thread and the event loop in one object: the deadline, the budget, and the order everything happens in | Server |
ServerTickRateManager | how long this tick is, whether game elements advance at all, and whether the loop is sprinting | Server |
TickTask | one queued runnable plus the MinecraftServer.tickCount it was submitted on — the age the budget reads | any thread submits, Server runs |
PacketProcessor | which serverbound packets are waiting, and that all of them are handled at the top of a tick | Netty fills it, Server empties it |
ServerLevel | one dimension’s step — a lecture of its own | Server |
ServerConnectionListener | which connections are still alive, and when each one’s player takes its own tick | Server |
ServerCommonPacketListenerImpl | whether a packet handed to a player is written, or written and flushed | Server, mostly |
ServerChunkCache.MainThreadExecutor | what the leftover milliseconds buy, one queue per level | Server |
One lap, ending on the wire
sequenceDiagram
participant PP as PacketProcessor
participant MS as MinecraftServer
participant SL as ServerLevel
participant SGPL as ServerGamePacket<br/>ListenerImpl
participant Conn as Connection
participant Wire as the network
Note over MS,SGPL: MinecraftServer.runServer moves MinecraftServer.nextTickTimeNanos past the backlog if it warns
MS->>PP: processQueuedPackets
PP->>SGPL: each packet Netty queued since the last drain, handled on its listener
rect rgba(0, 0, 0, 0.04)
Note over PP,Wire: MinecraftServer.tickChildren, one pass
MS->>SGPL: suspendFlushing, on every player's listener
MS->>SL: tick(haveTime), each dimension in turn, overworld first
SL->>SGPL: send, per player
SGPL->>Conn: send, with no flush while the flag holds
MS->>Conn: Connection.tick on each, from ServerConnectionListener.tick
Conn->>Conn: flushQueue drains pendingActions
Conn->>SGPL: tick, the player's own tick
Conn->>Wire: flush one, the levels and the player tick
MS->>SGPL: the next chunk batch, through its PlayerChunkSender
MS->>SGPL: resumeFlushing, per player, right after that batch
SGPL->>Conn: flushChannel
Conn->>Wire: flush two, the chunks and the rest of the phase
end
Note over MS,SGPL: MinecraftServer.waitUntilNextTick runs all tasks, then BlockableEventLoop.managedBlock parks
One tick, and the two moments the tick’s own packets reach the socket; the
shaded band is MinecraftServer.tickChildren, the stretch in which a playing
client’s packets wait to be flushed.
Follow the two arrows into the network: they carry what the tick itself sends a playing client, and the second one is the one the chunks ride.
The deadline moves before the work starts
MinecraftServer.runServer re-reads this tick’s length every iteration from
TickRateManager.nanosecondsPerTick and adds it to
MinecraftServer.nextTickTimeNanos before calling anything. Being behind
means the wall clock has already passed that field. The overload branch fires
when it has passed by more than MinecraftServer.OVERLOADED_THRESHOLD_NANOS
(one second) plus MinecraftServer.OVERLOADED_TICKS_THRESHOLD ticks’ worth —
two seconds in all at the default rate — and the last warning is at least
MinecraftServer.OVERLOADED_WARNING_INTERVAL_NANOS (ten seconds) plus
MinecraftServer.OVERLOADED_TICKS_WARNING_INTERVAL ticks’ worth behind it,
fifteen seconds in all at the default rate. Both effects — the log line, and
MinecraftServer.nextTickTimeNanos jumping over the missed ticks — are
statements of that one branch. Lateness therefore has to accumulate before
either happens: a server running every lap ten percent long says nothing while
the shortfall piles up, then warns and drops the pile in one step. And because
MinecraftServer.lastOverloadWarningNanos is set to the new deadline, the
second gate is fifteen seconds of the server’s own scheduled time — which on
an overloaded server is rather more than fifteen seconds of yours. In between,
the backlog is real and every tick of it is run.
Sprinting takes the other arm of the loop’s opening if, the one the overload
check sits inside: when the server is not paused,
ServerTickRateManager.isSprinting is true and
ServerTickRateManager.checkShouldSprintThisTick consents, this tick is
declared zero nanoseconds long and MinecraftServer.nextTickTimeNanos is
set to now. TickRateManager.nanosecondsPerTick still reads 50 ms
throughout — a sprint changes the length of this tick, gives the overload
check nothing to measure, and sends the loop straight back for another. Sprinting also unfreezes the game —
ServerTickRateManager.requestGameToSprint remembers the old state in
ServerTickRateManager.previousIsFrozen — and restores it when
ServerTickRateManager.finishTickSprint reports the measured rate.
Every packet since last time, in one drain
MinecraftServer.processPacketsAndTick opens the frame for the Tracy profiler, then calls
PacketProcessor.processQueuedPackets — before MinecraftServer.tickServer,
so the frame a profiler shows includes the packets. Each entry in that
ConcurrentLinkedQueue is a PacketProcessor.ListenerAndPacket: a Netty
thread decoded the packet, the handler called
PacketUtils.ensureRunningOnSameThread, and that queued the pair here and
aborted the Netty-side call by throwing RunningOnDifferentThreadException
(the connection
has the crossing, in both directions). This is where most
player input enters the world, but not all of it: the handlers that never call PacketUtils.ensureRunningOnSameThread reach the Server thread another way, when they reach it at all.
Fifty-one of ServerGamePacketListenerImpl’s sixty-one game handlers open
on that call, and the ten that do not divide into four kinds
(the handlers that never hop
lists them). Two really touch nothing — the ping reply and an empty
custom-payload hook. Four — ServerGamePacketListenerImpl.handleChat, both
command packets and the command-suggestion request — run their work through
MinecraftServer.execute (the suggestions by way of a
ServerCommandSuggestionsProvider, which keeps only the latest request,
answers at most one a tick, and leaves one it has no budget for to the
player’s own tick), and two more, filtered sign and book text, come
back on a CompletableFuture completed against the server: so chat and
commands arrive as tasks, drained by the event loop below, and not with the
packets. The last two write listener state on Netty
outright, because there is nothing to hand it to — a chat acknowledgement
advances LastSeenMessagesValidator under a lock, and the acknowledgement that
ends the play phase installs the configuration listener. And the chat family
does a little real work before it hands over:
ServerGamePacketListenerImpl.tryHandleChat reads the sender’s chat
visibility and resets its last-action time on the Netty thread, which is
the one place player state is written off the main thread by design.
Two gates sit on each queued pair. PacketListener.shouldHandleMessage is
asked again at handling time, so a player who disconnected between arrival and
now is dropped with a debug line rather than handled into a dead session. And
a handler that throws does not end the tick: ServerPacketListener overrides
PacketListener.onPacketError to log “suppressing error” and return, and
ServerCommonPacketListenerImpl.onPacketError additionally files the
throwable through MinecraftServer.reportPacketHandlingException into the
SuppressedExceptionCollector that the next crash report dumps. Two things
escape: a ReportedException wrapping an OutOfMemoryError, which
PacketUtils.makeReportedException builds for the processor to throw, and any
Error, which the catch does not cover.
An empty server stops ticking
Before anything else MinecraftServer.tickServer compares
MinecraftServer.emptyTicks against MinecraftServer.pauseWhenEmptySeconds
times twenty — the pause-when-empty-seconds property, default 60 in
DedicatedServerProperties, and zero on the base class, which disables the
feature. The counter advances only while nobody is online and the loop is
not sprinting; on the tick it first reaches the threshold the server logs,
autosaves once, and from then on runs MinecraftServer.tickConnection and
returns before the rest of the tick. Only the dedicated server pauses this
way, and its override still ticks the JSON-RPC ManagementServer after that
return. MinecraftServer.tickCount does not advance, so a paused server
is stopped in every sense that matters and still answers pings.
The integrated server pauses on a different signal.
IntegratedServer.tickServer sets IntegratedServer.paused from
Minecraft.isPaused or an empty player list, saves once on the way in, runs
IntegratedServer.tickPaused — connections plus one statistic — instead of
the real tick, and re-syncs the world time on the way out. It also copies the
client’s render and simulation distance into the PlayerList on every
unpaused tick, which is why singleplayer has no separate view-distance
setting.
That pause is the pattern for every difference between the two servers on
this page. The loop itself is written once: IntegratedServer and
DedicatedServer override pieces of the tick and never the loop, so
DedicatedServer.tickServer adds the JSON-RPC ManagementServer.tick,
DedicatedServer.tickConnection the console, and IntegratedServer.tickServer
the pause above. Starting a
server
is how the thread that runs MinecraftServer.runServer comes to exist.
What MinecraftServer.tickChildren runs, and in what order
MinecraftServer.tickChildren is the tick, and the rows below are its
order. All but the first are a profiler section of their own; suspending the
flush has none, and the debug row is three:
| in order | what it does | skipped when |
|---|---|---|
| suspend flushing | ServerCommonPacketListenerImpl.suspendFlushing on every player’s connection | never |
| command functions | ServerFunctionManager.tick runs ServerFunctionManager.LOAD_FUNCTION_TAG once after a reload, then ServerFunctionManager.TICK_FUNCTION_TAG | frozen |
| clocks | ServerClockManager.tick advances the world clocks, a SavedData kept in the world_clocks file | frozen, or GameRules.ADVANCE_TIME is off |
| time sync | MinecraftServer.forceGameTimeSynchronization broadcasts a ClientboundSetTimePacket | not a multiple of 20 ticks |
| levels | MinecraftServer.updateEffectiveRespawnData, then ServerLevel.tick for each dimension in MinecraftServer.getAllLevels order, overworld first | never |
| connection | MinecraftServer.tickConnection — every Connection, and each playing client’s own tick | never |
| players | PlayerList.tick broadcasts a latency-only ClientboundPlayerInfoUpdatePacket | its own counter has not passed 600, so it fires on every 601st call |
| debug, game tests, tickables | ServerDebugSubscribers.tick, GameTestTicker.tick, the dedicated server GUI’s refresh through MinecraftServer.addTickable | game tests alone, when frozen |
| send chunks | PlayerChunkSender.sendNextChunks, then ServerCommonPacketListenerImpl.resumeFlushing, per player | never |
Three rows say frozen, and that is TickRateManager.runsNormally answering
false. /tick freeze sets TickRateManager.isFrozen, and
TickRateManager.tick turns that into this tick’s
TickRateManager.runGameElements — unless /tick step left
TickRateManager.frozenTicksToRun above zero, which it also decrements.
Everything that consults TickRateManager.runsNormally stops: the functions,
the clocks and the game tests in the table above, and inside a level every step
the level tick marks
running, every entity but a player or what carries one, and a player’s
pushing of entities and the movement packets’ rate check.
A throwable out of ServerLevel.tick is caught, filled with the level’s
details as “Exception ticking world” and rethrown as a ReportedException
— which is how one bad dimension ends the whole server. Nothing else in the
list is wrapped. Two things a reader looks for here are elsewhere: the
/schedule queue is a TimerQueue the server owns and persists
(MinecraftServer.getScheduledEvents) but ticks from inside
ServerLevel.tickTime, which runs in the overworld alone and off the
overworld’s gameTime (the level
tick), and the last
statement of MinecraftServer.tickChildren is ServerActivityMonitor.tick,
a rate-limited nudge to the NotificationManager rather than anything the
world can see.
Where a player’s own tick actually happens
The connection phase runs after every level.
ServerConnectionListener.tick walks its synchronized list and, for each live
Connection, calls Connection.tick: drain Connection.pendingActions, the
one queue a Connection owns and which holds closures rather than packets;
tick the
TickablePacketListener; drop the connection if it has died; flush the
channel; and every twentieth tick recompute the packet-rate averages. For a
playing client that listener is ServerGamePacketListenerImpl, whose
ServerGamePacketListenerImpl.tick acknowledges pending block changes, runs
ServerPlayer.doTick through ServerGamePacketListenerImpl.tickPlayer, and
then, only if that returns without having kicked anyone (or at once, with no
player tick, while the server is paused), four more things in this order: the
fifteen-second keep-alive
(ServerCommonPacketListenerImpl.LATENCY_CHECK_INTERVAL), the three spam
throttles, any command-suggestion request still waiting, and the idle-timeout
check.
So a movement packet is applied to the player before any level ticks, and the
player entity takes its step after all of them. Entities see the player
where the packets put her; the player then ticks against a world that has
already moved. An exception out of Connection.tick ends the tick only on singleplayer’s
in-memory channel; elsewhere what it ends depends on the channel rather than
on the fault (the
connection). On a
dedicated server DedicatedServer.tickConnection adds
DedicatedServer.handleConsoleInputs, which is how a command typed at the
console reaches the Server thread. RCON does not come this way:
DedicatedServer.runCommand puts the command on the task queue with
BlockableEventLoop.executeBlocking and waits for the answer, so it runs
wherever the queue next drains. Players and sessions is
what happens inside that phase; the
connection
is the channel underneath it.
The two writes each client gets
ServerCommonPacketListenerImpl.suspendFlushing at the top of
MinecraftServer.tickChildren sets a flag that turns
ServerCommonPacketListenerImpl.send into a channel write with no flush —
but only for sends made on the Server thread, because the flag is tested
together with BlockableEventLoop.isSameThread, so anything sent from another
thread flushes on its own as before. Two things then empty the buffer.
Connection.tick flushes the channel unconditionally, in the middle of its
own body and so inside the connection phase, carrying everything the levels
and the player’s own tick produced. ServerCommonPacketListenerImpl.resumeFlushing, after the chunk
batch, both clears the flag and calls Connection.flushChannel itself,
carrying the player-list update, the debug subscribers, the game-test ticker,
the server’s own tickables and last the chunks.
Two writes to the socket per client come out of
MinecraftServer.tickChildren: one after the levels, one after the chunks. A
reply sent while the packets are drained before it, or from a task after it,
is flushed on its own.
The pacing of that second write belongs to what the client is
told;
which chunks are eligible to be in it, to tickets and
loading.
PlayerChunkSender
answers to the client’s own acknowledgements, so a slow client throttles its
own chunks without slowing the tick.
The bookkeeping at the bottom
MinecraftServer.tickServer closes with three pieces of bookkeeping. The cached
ServerStatus is rebuilt when the old one is more than
MinecraftServer.STATUS_EXPIRE_TIME_NANOS (five seconds) old, so a ping never
costs a walk of the player list. MinecraftServer.ticksUntilAutosave counts
down to MinecraftServer.autoSave, and it counts ticks while promising
minutes: it starts at MinecraftServer.AUTOSAVE_INTERVAL (6000) and is
thereafter MinecraftServer.computeNextAutosaveInterval, the tick rate times
300, floored at MinecraftServer.MIMINUM_AUTOSAVE_TICKS (100 — the typo is
Mojang’s). So an autosave is five minutes of the server’s scheduled time at any steady /tick rate, and at any set rate the floor never binds,
since the rate cannot go below 1; MinecraftServer.onTickRateChanged re-derives the figure
whenever that changes, but only ever shortens the pending countdown, and a sprint
uses the measured rate from MinecraftServer.getAverageTickTimeNanos so that
it saves at the speed it is really running. And the tick’s own duration replaces its
slot in the hundred-entry MinecraftServer.tickTimesNanos ring, updates
MinecraftServer.aggregatedTickTimesNanos, and folds into
MinecraftServer.smoothedTickTimeMillis at
MinecraftServer.AVERAGE_TICK_TIME_SMOOTHING. That ring is what /tick query
reads, through MinecraftServer.getAverageTickTimeNanos and
MinecraftServer.getTickTimesNanos.
The debug screen’s TPS chart is a different pipe: a SampleLogger with one
slot per TpsDebugDimensions value, written from three points of the loop.
MinecraftServer.logTickMethodTime records the tick method,
MinecraftServer.finishMeasuringTaskExecutionTime the scheduled-task and idle
slots after the wait, and MinecraftServer.logFullTickTime at the very bottom
both flushes the four-slot sample and measures the whole iteration, tick
plus wait. IntegratedServer logs it always; a dedicated server only while a
client is subscribed to DebugSubscriptions.DEDICATED_SERVER_TICK_TIME.
The loop is instrumented in three more places, none of them that ring.
Every iteration gets a fresh ProfilerFiller from
MinecraftServer.createProfiler, composing the MetricsRecorder’s profiler
with a SingleTickProfiler, and /debug start arms
MinecraftServer.TimeProfiler at the top of an iteration through
MinecraftServer.debugCommandProfilerDelayStart. MinecraftServer.tickFrame
is a DiscontinuousFrame from TracyClient, opened before the packet drain
and closed after the tick. JvmProfiler is fed
MinecraftServer.smoothedTickTimeMillis after the wait, on the last line of
the iteration — where MinecraftServer.isReady is also set, every lap rather
than once.
The event loop, and what a tick’s spare time buys
MinecraftServer extends ReentrantBlockableEventLoop of TickTask: it is
an Executor whose queue drains on the Server thread, and submitting a task to
it is how most other threads touch server state — a packet takes the
processor’s queue instead, and a console line the console’s list. This section
is where the rest of the book sends you for that machinery.
flowchart TD
P["BlockableEventLoop.pollTask peeks the head"] --> E{"anything queued"}
E -- "a task" --> B{"blocking depth above zero"}
B -- "inside BlockableEventLoop.managedBlock" --> RUN["run it, report true"]
B -- "not blocked" --> S{"MinecraftServer.shouldRun"}
S -- "older than three ticks" --> RUN
S -- "otherwise" --> H{"MinecraftServer.haveTime"}
H -- "time left" --> RUN
H -- "out of time, left queued" --> OFF["nothing ran from the server's own queue"]
E -- "nothing queued" --> OFF
The one question the head of the queue is asked, and the three independent reasons the answer is yes; the two edges into the bottom box are the ones the next figure picks up.
Read the bottom box twice. An empty queue and a queue whose head may not run
yet are the same answer to MinecraftServer.pollTaskInternal, and that answer
is the first of the two things a level’s chunk source waits for; the guard in
the next figure is the second.
Every runnable becomes a TickTask
MinecraftServer.wrapRunnable stamps the current MinecraftServer.tickCount
onto every task queued for the server, from whichever thread. That stamp is
the whole of a task’s identity to the scheduler: MinecraftServer.shouldRun
lets a task run when there is time left, or when it is older than
MinecraftServer.MAX_TICK_LATENCY (three) ticks — so a saturated server still
drains its queue, late but in order and without unbounded growth. Submitting
from the Server thread runs the work inline unless a task is already running:
ReentrantBlockableEventLoop.scheduleExecutables reports true then, so
re-entrant work queues instead of nesting. MinecraftServer.scheduleExecutables
and MinecraftServer.executeIfPossible, the two doors into the queue, both
answer differently once the server has stopped, which is the first move of how a server
dies.
A task that throws an exception is not the loop’s problem either.
BlockableEventLoop.doRunTask logs the failure under the fatal marker and
returns, rethrowing only what BlockableEventLoop.isNonRecoverable calls
unrecoverable — a ReportedException wrapping an OutOfMemoryError or a
StackOverflowError — while an Error thrown bare passes its catch untouched.
On a dedicated server a worker thread that dies surfaces here too, as a throw
out of BlockableEventLoop.pollTask rather than out of anything the tick
called: that is the relay how a server
dies describes, and what it becomes — the crash report, the shutdown the loop’s
finally performs, and the watchdog that reads
MinecraftServer.getNextTickTime from outside the loop and halts the JVM
without saving — is that page’s whole subject.
The budget, and where it stops applying
MinecraftServer.haveTime is the BooleanSupplier the whole tick is handed.
It is true unconditionally while a task is running
(ReentrantBlockableEventLoop.runningTask), and otherwise compares the clock
against MinecraftServer.delayedTasksMaxNextTickTimeNanos or
MinecraftServer.nextTickTimeNanos depending on
MinecraftServer.mayHaveDelayedTasks — which MinecraftServer.runServer sets
true, together with the slack deadline, immediately after the tick, and which
every subsequent MinecraftServer.pollTask overwrites with was there more.
It stops applying the moment the thread blocks. Inside
BlockableEventLoop.managedBlock the blocking depth is non-zero, so
BlockableEventLoop.shouldRunAllTasks is true and
BlockableEventLoop.pollTask never consults MinecraftServer.shouldRun:
every queued task runs, budget and age irrelevant. A level that blocks on a
chunk mid-tick does the same on its own ServerChunkCache.MainThreadExecutor,
whose tasks always run, and does not deadlock for the same reason — the wait
is the drain, and the thread doing the waiting is the thread that completes
the thing it waits for. MinecraftServer.waitUntilNextTick is the mechanism
used deliberately: BlockableEventLoop.runAllTasks, then
BlockableEventLoop.managedBlock on no time left.
MinecraftServer.waitForTasks parks with LockSupport until
MinecraftServer.nextTickTimeNanos — or 100 µs at a time when the loop is not
waiting on a tick — and BlockableEventLoop.schedule unparks it, so a
submitted task cuts the park short. PacketProcessor.scheduleIfPossible
pointedly does not: a packet landing in the slack waits for the next drain.
What the budget actually gates
Three things are all MinecraftServer.haveTime decides inside the tick,
once it has travelled from MinecraftServer.tickServer through
MinecraftServer.tickChildren, ServerLevel.tick, ServerChunkCache.tick
and ChunkMap.tick.
They are ChunkMap.processUnloads (the unload queue, which drains anyway
while it holds more than two thousand entries), ChunkMap.saveChunksEagerly
(at most twenty chunks a tick, and only under 128 outstanding writes) and
SectionStorage.tick by way of PoiManager.tick (the village-point data of
dirty chunks being written out). Loading a chunk, generating one, propagating
tickets and ticking chunks take no supplier and are not gated at all. A late
server does not load fewer chunks, then; it postpones unloading and saving
them, and its memory grows while it is behind.
The slack, and the sprint that inverts it
After the tick, MinecraftServer.waitUntilNextTick spends the remaining
milliseconds. MinecraftServer.pollTaskInternal polls the server’s own queue
first and, only if that queue had nothing to run, offers the levels’
ServerChunkCache.MainThreadExecutor.pollTask a turn, one after another until
one runs something — when the loop is sprinting, or blocked, or still in time. The levels get the leftovers of the
leftovers.
flowchart TD
N["nothing ran from the server's own queue"] --> G{"sprinting, blocked or in time"}
G -- "no" --> W["report false, and MinecraftServer.waitForTasks parks"]
G -- "yes" --> C{"any level's chunk source with work"}
C -- "one had" --> CRUN["a chunk source ran, report true"]
C -- "none did" --> W
What the leftover milliseconds buy: the guard that has to pass before the levels are asked at all, and the two ways the poll still ends in a park.
That executor keeps a policy of its own: its ServerChunkCache.MainThreadExecutor.shouldRun is unconditionally
true, with no age rule, and its poll runs
ServerChunkCache.runDistanceManagerUpdates first and returns at once if that
did any work. The first thing leftover milliseconds buy is chunks changing
status; the light schedule and the one queued chunk task only happen on a poll
where the graphs were already quiet.
Sprinting inverts the arithmetic. MinecraftServer.processPacketsAndTick
hands MinecraftServer.tickServer a constant false instead of
MinecraftServer.haveTime, so unloading, eager saving and section flushing
stop for the length of the sprint — and yet ServerTickRateManager.isSprinting
is the first term of the condition guarding the chunk-source poll, so the
levels are offered the turn on every poll regardless. A sprint therefore does
more chunk work per wall-clock second than an ordinary server, not less,
while doing little of the housekeeping that would let the results reach the
disk — the exceptions being the unload queue, which the two-thousand rider
drains whether there is time or not, and the autosave, which keeps counting.
Questions players ask
Does freezing stop the server? It stops the world, not the loop.
MinecraftServer.tickCount still increments, connections still tick and
packets are still answered, so a frozen server is one you can log into, walk
about in and type commands at — TickRateManager.isEntityFrozen exempts
players and anything carrying one — while nothing around you moves.
Is the tick rate settable to anything? Between
TickRateManager.MIN_TICKRATE (1.0) and TickCommand.MAX_TICKRATE (10000).
Clients are told: ServerTickRateManager.setTickRate and
ServerTickRateManager.setFrozen broadcast a ClientboundTickingStatePacket,
ServerTickRateManager.stepGameIfPaused a ClientboundTickingStepPacket, and
ServerTickRateManager.updateJoiningPlayer sends both to anyone arriving. A
sprint is not announced as such — a client is sent an unfrozen state as it starts
and, if the game was frozen, the refreeze after.
Why is an empty Nether nearly free? Because a dimension that no player’s
simulation ticket, forced chunk, portal or ender pearl keeps active stops
ticking entities, block entities and any dragon fight after 300 ticks of
ServerLevel.emptyTime, while still running its chunk-source work. That rule
belongs to the level
tick, and tickets and
loading owns which ticket
resets it.
Where to look
MinecraftServer.runServer · MinecraftServer.processPacketsAndTick ·
MinecraftServer.tickServer · MinecraftServer.tickChildren ·
MinecraftServer.waitUntilNextTick · MinecraftServer.haveTime ·
BlockableEventLoop · PacketProcessor · PacketUtils · TickRateManager ·
ServerTickRateManager · ServerConnectionListener.tick · Connection.tick ·
ServerCommonPacketListenerImpl.send · ServerChunkCache.MainThreadExecutor ·
IntegratedServer · DedicatedServer
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
The level tick
Verified against Minecraft 26.3 · Part III · A player stands still for one twentieth of a second while a whole dimension advances — weather, scheduled ticks, spawns, every entity, every block entity.
Nobody moves. A wheat crop is one random tick short of ripe, a creeper is
walking a fence line, rain has been falling for four minutes and a piston
somewhere is halfway through extending. Then ServerLevel.tick runs, once,
for this dimension, and all of it advances: the border interpolates, the
weather counts down, the scheduled block ticks fire, mobs are counted and
spawned, chunks near players get their random ticks, every entity in range
runs its Entity.tick, block entities run their tickers, and the block
changes that all of that produced are handed to the clients. It is one
method on the Server thread, called once per dimension from
MinecraftServer.tickChildren (the server
tick),
overworld first. The order inside it is the whole lecture, because one of the steps is
in a place nobody expects: the block-change broadcast runs before the
entities tick. ServerChunkCache.broadcastChangedChunks comes before
ChunkMap.tick and long before EntityTickList.forEach, so a block a
command changed between two ticks reaches your screen in the tick that
follows, and a block a piston changed during that tick reaches it a tick
later.
Three ranges, before we need them
Three phrases run through everything below and they are all one number line,
which counts outwards: a low level is a chunk somebody is standing in and a
high one is a chunk at the edge of what the server bothers with, up to
ChunkLevel.MAX_LEVEL. A chunk is loaded when it has a ChunkHolder at
all; it is
block-ticking at level 32 or below (ChunkLevel.BLOCK_TICKING_LEVEL) and
entity-ticking at 31 or below (ChunkLevel.ENTITY_TICKING_LEVEL), and
those last two answers come from the simulation graph, through
DistanceManager.inBlockTickingRange and
DistanceManager.inEntityTickingRange. How a chunk gets its level — which
tickets put it there and which graph they feed — is Part IV’s
tickets and loading; for this page it is
enough that block-ticking reaches one chunk further out than entity-ticking,
and that both are brought up to date inside this tick, before anything but the
scheduled ticks and the raids reads them.
The cast
| class | what it decides | thread |
|---|---|---|
ServerLevel | the order of the tick, and the gates on its own steps — one instance per dimension | Server |
ServerChunkCache | the chunk half of the tick: ticket purging, distance updates, spawning, random ticks, the broadcast | Server |
ChunkMap | which chunks are candidates for spawning, which are entity-ticking, and every player’s view of every entity | Server |
ChunkHolder | one chunk’s pending block and light changes, and which packet shape they become | Server |
LevelTicks | the two scheduled-tick queues, one LevelChunkTicks per chunk, drained by priority | Server |
EntityTickList | which entities are ticked, and a stable view of that set while it is being walked | Server |
PersistentEntitySectionManager | which entities exist and which of them are ticking — the tick list’s only editor | Server; its inbox is filled by IO threads |
TickRateManager | whether this is a normal tick at all, through TickRateManager.runsNormally | Server |
ServerLevel is one of the two subclasses of the abstract Level, and the
split shows in this page’s own names. Level holds what a world has on
either side — the block-entity ticker list, the CollectingNeighborUpdater,
the two weather floats, Level.random and the level data — and leaves the
questions only an authority can answer abstract, Level.sendBlockUpdated and
Level.tickRateManager among them. ServerLevel answers those and adds the
authority itself: the chunk source, the two scheduled-tick queues, the entity
manager, the list of players. So where a step below is spelled with
Level rather than ServerLevel the client inherits it too, and the client
level is the list
of the ones it has hollowed out.
The whole tick, and its three gates
The tick is one method calling its steps one after another, so its shape is
not a conversation — it is a column with guards down the side. There are three
guards, and every step is behind one of them, a combination of them, or
nothing: running (TickRateManager.runsNormally, false while
/tick freeze holds and no step is pending), not a debug world (Level.isDebug,
true only in the Debug Mode world type), and the dimension is not empty (ServerLevel.emptyTime
below ServerLevel.EMPTY_TIME_NO_TICK, 300).
flowchart TD
START["MinecraftServer.tickChildren calls ServerLevel.tick, overworld first"]
ENV["ServerLevel.handlingTick goes true, the attribute cache dropped"]
WB["WorldBorder.tick, then ServerLevel.advanceWeatherCycle"]
SLEEP["the sleep check: the clock, the players, the weather"]
SKY["Level.updateSkyBrightness"]
TIME["ServerLevel.tickTime, overworld only"]
SCHED["ServerLevel.blockTicks, then ServerLevel.fluidTicks"]
RAID["Raids.tick"]
subgraph SCC["ServerChunkCache.tick"]
direction TB
PURGE["TicketStorage.purgeStaleTickets"]
DIST["ServerChunkCache.runDistanceManagerUpdates"]
CHUNKS["spawns, random ticks, thunder, custom spawners"]
CAST["ServerChunkCache.broadcastChangedChunks"]
TRACK["ChunkMap.tick: entity tracking"]
UNLOAD["ChunkMap.tick with the budget: POI saves and unloads"]
PURGE --> DIST --> CHUNKS --> CAST --> TRACK --> UNLOAD
end
EVENTS["ServerLevel.runBlockEvents, then ServerLevel.handlingTick false"]
EMPTY["ServerLevel.emptyTime: reset by a ticket, otherwise rising"]
DRAGON["EnderDragonFight.tick, the End only"]
ENT["EntityTickList.forEach: each entity, then its riders"]
BE["Level.tickBlockEntities"]
EM["PersistentEntitySectionManager.tick: the inbox, then the unload set"]
DBG["LevelDebugSynchronizers.tick"]
GC["RandomState.garbageCollect"]
START --> ENV --> WB --> SLEEP --> SKY --> TIME --> SCHED --> RAID --> PURGE
UNLOAD --> EVENTS --> EMPTY --> DRAGON --> ENT --> BE --> EM --> DBG --> GC
The order, and the one containment in it: the six steps in the box are not siblings of the steps around them, they are the insides of a single call, and a later section counts them as five things.
The gates are the other half of the tick and they do not fit in the boxes. Read this table down the middle instead: a step with three blank cells runs on a frozen, empty, debug world.
| the step | running | not a debug world | the dimension is not empty |
|---|---|---|---|
ServerLevel.handlingTick true, the attribute cache dropped | |||
WorldBorder.tick, ServerLevel.advanceWeatherCycle | • | ||
| the sleep check | |||
Level.updateSkyBrightness | |||
ServerLevel.tickTime | • | ||
ServerLevel.blockTicks, ServerLevel.fluidTicks | • | • | |
Raids.tick | • | ||
TicketStorage.purgeStaleTickets | • | ||
ServerChunkCache.runDistanceManagerUpdates | |||
| spawns, random ticks, thunder, custom spawners | • | • | |
ServerChunkCache.broadcastChangedChunks | • | ||
ChunkMap.tick, entity tracking | |||
ChunkMap.tick with the budget | |||
ServerLevel.runBlockEvents | • | ||
ServerLevel.handlingTick false | |||
ServerLevel.emptyTime rises | • | ||
EnderDragonFight.tick | • | • | |
EntityTickList.forEach | • | ||
Level.tickBlockEntities | per ticker | • | |
PersistentEntitySectionManager.tick | |||
LevelDebugSynchronizers.tick | |||
RandomState.garbageCollect |
All but the last of those steps have names, and they are the names a
profiler reports: world
border and weather, tickPending (holding blockTicks and fluidTicks),
raid, chunkSource, blockEvents, entities (holding dragonFight,
checkDespawn and the per-entity tick), blockEntities,
entityManagement and debugSynchronizers. Half a dozen pages elsewhere in
the book place a mechanism by saying it runs after tickPending or in the
chunk source phase, and they mean these; so does a /debug profiling
report.
Read the gate columns and most of the page’s surprises fall out of the table. Sleeping through the night works with the game frozen. A frozen world still loads, sends and unloads chunks — and stops expiring its tickets. A debug world keeps its entities and drops its block updates. And the last three steps run on a dimension with nobody in it.
What the tick does before anything can move
The cache that is dropped before the border
The first thing the tick does to the world, before the border and before
the weather, is EnvironmentAttributeSystem.invalidateTickCache: last tick’s
resolved environment attributes are thrown away. That system
(environment attributes)
is where the old per-dimension and per-biome constants went, and
Level.updateSkyBrightness later in this same tick reads
EnvironmentAttributes.SKY_LIGHT_LEVEL out of it rather than deriving sky
light from the time of day. ServerClockManager invalidates the same cache
on every level whenever a clock is set, moved, paused or given a new rate, so
the level is not its only owner —
it is the first reader of the tick, and it starts clean.
The weather is the server’s; only the fade is the level’s
Then WorldBorder.tick advances the interpolated extent, and
ServerLevel.advanceWeatherCycle counts the clear, rain and thunder timers
down under GameRules.ADVANCE_WEATHER, resampling the rain and thunder
timers from ServerLevel.RAIN_DELAY, ServerLevel.RAIN_DURATION,
ServerLevel.THUNDER_DELAY and ServerLevel.THUNDER_DURATION as each
expires, and fading Level.rainLevel and Level.thunderLevel by 0.01 a
tick, which is why a downpour arrives as a five-second ramp. The countdowns
belong to the server: ServerLevel.getWeatherData delegates to
MinecraftServer.getWeatherData, one WeatherData shared by every
dimension, and the only per-level parts are those two floats and the
Level.canHaveWeather test that decides whether this dimension acts on any
of it. Every move of a float is a ClientboundGameEventPacket
(ClientboundGameEventPacket.RAIN_LEVEL_CHANGE,
ClientboundGameEventPacket.THUNDER_LEVEL_CHANGE) to this dimension’s
players, and a start or a stop goes, with both levels, to every player in
every dimension.
A freeze stops the clock and not the sleep check
SleepStatus.areEnoughSleeping and SleepStatus.areEnoughDeepSleeping
(against GameRules.PLAYERS_SLEEPING_PERCENTAGE) decide the night skip, and
they are checked outside every gate. If the dimension type has a default
clock and GameRules.ADVANCE_TIME is on,
ServerClockManager.moveToTimeMarker jumps that clock to
ClockTimeMarkers.WAKE_UP_FROM_SLEEP; ServerLevel.wakeUpAllPlayers gets
everyone out of bed, and, if it is raining and GameRules.ADVANCE_WEATHER is
on, ServerLevel.resetWeatherCycle clears the storm.
The ClientboundSetTimePacket that follows is sent by the clock manager,
not by the level — day time is not the level’s state at all.
What the level does own is gameTime, and only in the overworld:
ServerLevel.tickTime advances it when the level was built with its
ServerLevel.tickTime flag set, which MinecraftServer does for the
overworld alone, and every other dimension reads that number. The same call
advances the server-wide TimerQueue behind /schedule
(MinecraftServer.getScheduledEvents), so a scheduled function is timed off
overworld gameTime and stands still while the world is frozen.
Scheduled ticks, twice, and a promise to the same block
LevelTicks.tick is called twice — ServerLevel.blockTicks, then
ServerLevel.fluidTicks — each with the current gameTime and a budget of
ServerLevel.MAX_SCHEDULED_TICKS_PER_TICK, 65536, and both skipped entirely
in a debug world. Each call collects every LevelChunkTicks container whose
head is due and whose chunk passes
ServerLevel.isPositionTickingWithEntitiesLoaded, drains what the budget
allows, and hands each drained tick to ServerLevel.tickBlock or
ServerLevel.tickFluid — both of which check that what is at the position is
still what the tick named before running it. The order the drain uses, the
dedup rule that decides what was ever queued, and why that re-check is the
whole of cancellation belong to scheduled
ticks; what
belongs to this page is that the two calls happen here, in this order, under
this budget.
The chunk source does five things in one call
ServerChunkCache.tick receives the server’s MinecraftServer.haveTime
supplier (or, while sprinting, a constant false in its place) — the level never looks at it, it only passes it on — and does five
things in order.
- Purge stale tickets — while running only, so a frozen world holds on to expired portal and pearl tickets indefinitely.
- Run
ServerChunkCache.runDistanceManagerUpdates— ungated, and the tick’s own run of the updates that change a chunk’s ticking state. The reason an entity starts or stops ticking this tick is decided here, several steps before the entity loop reads it. - Tick the chunks, then broadcast — one call holding two halves, both skipped in a debug world: the spawning and random-ticking work below, and then the block changes turned into packets. Each half has a section of its own.
- Update entity tracking —
ChunkMap.tick, ungated, where last tick’s movement becomes packets. - Spend whatever time is left on POI saving and chunk unloads.
That last step is the only part of the whole level tick that yields to the
clock, and even it does not yield completely: ChunkMap.processUnloads
force-drains anything over two thousand queued unloads whatever the budget
says.
Two chunk sets, and two different mob caps
NaturalSpawner.createState walks ServerLevel.getAllEntities — every
entity in the dimension except the ones in MobCategory.MISC (items,
projectiles and armour stands, but villagers, golems and minecarts too), and
except mobs that require persistence — and
counts the rest per MobCategory, using the chunk each one stands in to charge a
spawn-potential field and to feed a LocalMobCapCalculator. Walking every
entity in the dimension is what this step costs, once a tick, before any
spawn is attempted; the two caps that census feeds — a server-wide one
scaled by the spawnable-chunk count and a per-player one that is not — belong to
entity lifecycle.
The categories marked persistent — the animals — are
considered only on a tick where gameTime divides by 400, and
GameRules.SPAWN_MOBS stops the spawns themselves, not the census before them
or the thunder beside them.
Two chunk sets are then walked, and they are not the same set.
ChunkMap.collectSpawningChunks gathers the spawning chunks: the chunks within eight of a player (the radius
DistanceManager.naturalSpawnChunkCounter counts) that have a ticking chunk and at least one
non-spectating player within 128 blocks
(ChunkMap.playerIsCloseEnoughForSpawning). They are shuffled, and each
then gets ChunkAccess.incrementInhabitedTime, then ServerLevel.tickThunder
if it is also entity-ticking, and then NaturalSpawner.spawnForChunk if
ServerLevel.canSpawnEntitiesInChunk. The thunder is a 1-in-100000 roll per
chunk per tick while raining and thundering, whose bolt prefers a lightning
rod, then a mob that can see the sky, then the heightmap, and which brings a
trap skeleton horse along at effective difficulty × 1 % when
GameRules.SPAWN_MOBS is on and no lightning rod is under the strike.
The second set is ChunkMap.forEachBlockTickingChunk, which despite its
name walks DistanceManager.forEachEntityTickingChunk — the entity-ticking
set, level 31 and below — and keeps only those whose ChunkHolder has a live
ChunkHolder.getTickingChunk. Each survivor gets ServerLevel.tickChunk.
Random ticks are counted per section, and empty sections are free
ServerChunkCache.tickChunks reads GameRules.RANDOM_TICK_SPEED (default 3,
minimum 0) once per level tick and hands it down, and
ServerLevel.tickChunk does two things with it. It rolls that many 1-in-48
chances at ServerLevel.tickPrecipitation — the ice and snow layer, using
Biome.shouldFreeze, Biome.shouldSnow and
GameRules.MAX_SNOW_ACCUMULATION_HEIGHT — and then, for every
LevelChunkSection in the chunk that reports
LevelChunkSection.isRandomlyTicking, picks that many random positions and
rolls both BlockBehaviour.BlockStateBase.randomTick and the fluid’s at
each. LevelChunkSection.isRandomlyTicking is a counter maintained on every
block change, so a section of solid stone costs nothing at all: the loop
over sections is a loop over the chunk’s interesting height. Set the rule
to zero and both halves stop, ice and snow included.
The position itself does not come from Level.random.
Level.getBlockRandomPos advances Level.randValue, a plain linear
congruential generator, and unpacks x, y and z out of one integer.
Everything the block then does with that position — the crop-growth roll,
fire spreading, every implementation of
BlockBehaviour.BlockStateBase.randomTick — takes Level.random, which is
also what the ice and snow rolls, the lightning roll and the spawning-chunk
shuffle use.
The custom spawners come last inside this step.
ServerLevel.tickCustomSpawners runs the five a level was constructed with,
which in practice means the overworld’s (the other ways
in names them). Three of
the five carry a game rule of their own (GameRules.SPAWN_PHANTOMS,
GameRules.SPAWN_PATROLS, GameRules.SPAWN_WANDERING_TRADERS); the other two carry none, and GameRules.SPAWN_MOBS gates the whole call — though VillageSiege, like the phantom and patrol spawners, also stops when GameRules.SPAWN_MONSTERS is off.
The broadcast, which is why entities are a tick behind
Almost nothing in the game sends a block update at the moment a block
changes — a landing FallingBlockEntity is the exception worth knowing, and
it is dealt with at the end of this section.
ServerLevel.sendBlockUpdated calls ServerChunkCache.blockChanged, which
finds the ChunkHolder, records the position in that holder’s per-section
set, and — the first time a holder gains a changed section — adds it to
ServerChunkCache.chunkHoldersToBroadcast. Then ServerChunkCache.blockChanged returns. The packets are
built once, later, in the chunk source’s step.
sequenceDiagram
participant SL as ServerLevel
participant SCC as ServerChunkCache
participant CH as ChunkHolder
participant Wire as the network
rect rgba(0, 0, 0, 0.04)
Note over SL,Wire: before the tick — a command changed a hundred blocks
SL->>SCC: blockChanged, once per block
SCC->>CH: blockChanged, one entry in that section's set
CH-->>SCC: nothing leaves, the holder is only marked
end
rect rgba(0, 0, 0, 0.04)
Note over SL,Wire: this tick, inside ServerChunkCache.tick
SCC->>CH: broadcastChanges, on every marked holder
CH->>Wire: ClientboundLightUpdatePacket, if either light filter has anything
CH->>Wire: one ClientboundBlockUpdatePacket per single-change section
CH->>Wire: one ClientboundSectionBlocks<br/>UpdatePacket per busier section
CH->>Wire: BlockEntity.getUpdatePacket beside any changed block entity
Note over SL,Wire: ChunkMap.tick then turns last tick's movement into packets
end
rect rgba(0, 0, 0, 0.04)
Note over SL,Wire: still this tick, several steps later
SL->>SL: tickBlockEntities: a piston head finishes its push
SL->>SCC: blockChanged, marked, and it waits for the next tick
end
Three bands: the write before the tick, the send inside it, and a write that arrives too late for the send. The dotted arrow in the first band is the hook — a block change leaves nothing on the wire at the moment it happens.
ChunkHolder.broadcastChanges asks two different questions of two
different audiences, and it asks the light one first: if either light filter
has anything in it, ChunkHolder.PlayerProvider.getPlayers is called with
the border-only flag set and one ClientboundLightUpdatePacket goes to the
players on the edge of their tracked area, before a single block packet is
built. The blocks are then emitted per 16³ section, to everyone tracking
the chunk, and the shape depends on how many blocks in that section changed: one
ClientboundBlockUpdatePacket for a single change, one
ClientboundSectionBlocksUpdatePacket for several, plus
BlockEntity.getUpdatePacket for any changed position that carries a block
entity. A hundred blocks changed by one command are therefore a handful of
packets, one per affected section — not a hundred.
Then ChunkMap.tick runs — chunk tracking for each player, and
ChunkMap.TrackedEntity for each entity, which is where
ServerEntity.sendChanges turns last tick’s movement into packets. Blocks
first, entities second, and the entity loop only after both. The ordering is
visible from a client: a player’s /setblock lands in the tick right after it
runs, because a player’s command runs as a task between two ticks, before
MinecraftServer.tickChildren starts, while a piston head, moved inside a tick
after that tick’s broadcast, lands in the tick after the one that moved it. A command typed at the console is as late
as the piston, because it too changes blocks after the broadcast: it is
drained in the connection phase,
which MinecraftServer.tickChildren runs after every level has already
broadcast.
Falling sand is the exception that proves the rule, and it is deliberate.
FallingBlockEntity calls LevelWriter.setBlockAndUpdate and then, straight
after, ChunkMap.sendToTrackingPlayers twice: a
ClientboundBlockUpdatePacket of its own, and a
ClientboundAddTransientBlockPacket the client hands its renderer as a
TransientBlock — so the block the client sees appear is sent in the same tick the entity
that placed it vanished, rather than a tick behind it. A handful of other
places do the same thing to one player rather than to everyone tracking the
chunk. What the client does with all of this is
what the client is told.
Block events close the handlingTick window
ServerLevel.runBlockEvents drains ServerLevel.blockEvents — the
note-block plays, piston pushes and chest-lid counts queued since the last
drain, and any the drain itself raises — running every event inside the
block-ticking range and putting the rest back for the next one, and this is the only point in the tick at which it happens.
What the queue promises the blocks in it, and why a piston told about a change
by a packet handler still moves in the same tick this phase belongs to, is
pistons and block
events.
ServerLevel.handlingTick went true at the very top of the tick and goes
false here, so the whole entity half runs outside that window. Its one
reader in the game is PistonBaseBlock, which uses it to tell a piston
update raised inside the tick from one raised outside it.
An empty dimension skips exactly three things
ServerChunkCache.hasActiveTickets — really
TicketStorage.shouldKeepDimensionActive, which is the players’ simulation
tickets and the forced, portal and ender-pearl tickets — resets ServerLevel.emptyTime at this point in the tick, and two other calls reset it: Entity.teleportCrossDimension for the level a non-player entity arrives in, and ServerPlayer.registerAndUpdateEnderPearlTicket, from a thrown pearl’s own tick in the entity loop, for the pearl’s level. Otherwise
the counter rises, and it rises only while running, so a frozen dimension
never falls asleep. Past ServerLevel.EMPTY_TIME_NO_TICK, 300 ticks, the
level skips the dragon fight, the entity loop and the block entities. That
is the entire skip: the weather, the scheduled ticks, the chunk source, the
block events, the entity manager’s load and unload drain and the debug feed
all keep running on a dimension nobody has visited for fifteen seconds.
Every entity, and then its riders
EntityTickList.forEach walks the tick list. An entity is skipped if it has
been removed, or if TickRateManager.isEntityFrozen says so — frozen, and
neither a player nor something carrying one. Otherwise it gets
Entity.checkDespawn, and then ticks only if it is a ServerPlayer or its
chunk answers DistanceManager.inEntityTickingRange. A passenger whose
vehicle is alive and still lists it is passed over here and ticked by the
vehicle instead; a stale link is broken with Entity.stopRiding.
ServerLevel.tickNonPassenger calls Entity.commonTick, which counts
Entity.invulnerableTime down, records the old position and bumps
Entity.tickCount, and then Entity.tick; ServerLevel.tickPassenger
runs Entity.rideTick for each rider that is a Player or in the tick
list, recursively down the stack. Level.guardEntityTick wraps each one in
a crash report titled Ticking entity, so a mob that throws names itself.
The list stays stable under all of that by construction. Membership changes
during the loop — a bred baby, a fired arrow, a bolt a trident calls down —
arrive
through ServerLevel.EntityCallbacks.onTickingStart immediately.
EntityTickList allows exactly one EntityTickList.forEach at a time and,
on the first add or remove while it is iterating, copies into its
EntityTickList.passive map and swaps that with EntityTickList.active:
the running loop keeps walking the view it started with, and the new entity
waits for the next tick. Which entities are in the list at all is
PersistentEntitySectionManager’s answer, through
Visibility.fromFullChunkStatus — only FullChunkStatus.ENTITY_TICKING
maps to a ticking visibility — with one override, Player.isAlwaysTicking,
which keeps a player in the list whatever its chunk is doing; the
ServerPlayer test inside the loop is the other half of that exemption,
letting the player past the range check. Entities are
Part VI’s subject: entity
lifecycle.
Block entities reach one chunk further than mobs
Level.tickBlockEntities walks Level.blockEntityTickers, dropping removed
tickers as it goes and running the rest — but only those whose position
passes ServerLevel.shouldTickBlocksAt, which is the block-ticking
range, and only while TickRateManager.runsNormally. That single condition
is why a furnace keeps smelting one chunk further out than a zombie keeps
walking. A block entity created during the walk — a sculk sensor a catalyst’s
spread has just grown — lands on Level.pendingBlockEntityTickers instead,
because Level.tickingBlockEntities is true, and is merged in when the next
tick’s walk begins. Block
entities has the
rest.
After the entities: the manager’s drain and the debug feed
PersistentEntitySectionManager.tick drains
PersistentEntitySectionManager.loadingInbox, a concurrent queue that chunk storage fills on the Server thread once an IO read returns — entities from freshly loaded chunks join the
world here — and then processes
PersistentEntitySectionManager.chunksToUnload. The
ServerLevel.EntityCallbacks it fires
(ServerLevel.EntityCallbacks.onTickingStart,
ServerLevel.EntityCallbacks.onTickingEnd) are exactly what add and remove
entries in the tick list the previous step walked.
Then LevelDebugSynchronizers.tick pushes this tick’s changed brains, paths and raids to any client subscribed through DebugSubscriptions — and, just
before it, arms or clears the neighbour listener on CollectingNeighborUpdater,
which sends each neighbour update the moment it happens, depending on whether
anyone is subscribed to DebugSubscriptions.NEIGHBOR_UPDATES at all. It is the
one step of the level tick whose entire output is diagnostic, and it is outside
every gate. The last step is RandomState.garbageCollect, which drops the
density buffers world generation pooled and no longer uses.
What leaves the level, and when
| the packet | the step that sends it | to whom |
|---|---|---|
ClientboundGameEventPacket, rain and thunder levels | ServerLevel.advanceWeatherCycle | this dimension’s players, and everyone on a start or a stop |
ClientboundGameEventPacket, start and stop raining | the same step, on a transition | every player in every dimension |
ClientboundSetTimePacket | ServerClockManager.moveToTimeMarker, from the sleep skip | everyone, sent by the clock manager |
ClientboundBlockUpdatePacket · ClientboundSectionBlocksUpdatePacket | ChunkHolder.broadcastChanges, per 16³ section | everyone tracking the chunk |
BlockEntity.getUpdatePacket | the same walk, beside a changed position that has one | everyone tracking the chunk |
ClientboundLightUpdatePacket | the same walk, before the blocks | the players at the edge of their tracked area |
| entity moves, and the adds and removes a move causes | ChunkMap.TrackedEntity, inside ChunkMap.tick | whoever tracks that entity |
ClientboundBlockEventPacket | ServerLevel.runBlockEvents | players within 64 blocks |
None of them go out when they are written. Every one is queued behind the
suspended flush that MinecraftServer.tickChildren opens around all the
levels, and leaves on the wire when Connection.tick flushes the channel in
the connection phase, after every level has ticked
(the server tick).
Questions players ask
Does /tick freeze stop the server doing work? Barely, on the chunk
side. Distance updates, the block-change broadcast, entity tracking and
chunk unloads all run every tick regardless. Two things inside the chunk
source are frozen and are easy to miss: the spawning and random-ticking
step, and TicketStorage.purgeStaleTickets — so a frozen world accumulates
expired tickets and never releases the chunks they hold.
Why did replacing a block cancel its scheduled tick? Because a scheduled
tick is a promise to that block: ServerLevel.tickBlock compares the
Block at the position with the one scheduled, and a mismatch runs nothing.
Why does /weather rain in the Nether change the overworld? There is one
WeatherData on the MinecraftServer, and the command writes it from whatever
dimension it runs in; only a dimension that Level.canHaveWeather counts it
down. The fade of Level.rainLevel and Level.thunderLevel is per level, and
only there.
Why does an arrow a skeleton fires not move until the next tick?
EntityTickList swapped its maps the moment the arrow was added, so the loop
that is running kept the view it started with. The arrow is in the list — it
is just not in this walk of it. A mob that spawned naturally is ticked at
once: the chunk source added it before the walk began.
For a 1.21-era reader. The day–night cycle is out of the level. Time is a set of
WorldClocks owned byServerClockManagerand ticked by the server, and theClientboundSetTimePacketafter a night skip is sent by the clock manager rather than by the level. WhatServerLevelstill owns is gameTime, advanced byServerLevel.tickTimein the overworld alone, which every other dimension reads. The weather countdowns went the same way, into oneWeatherDataon theMinecraftServer.
Where to look
In the order the tick runs them. ServerLevel.tick ·
EnvironmentAttributeSystem.invalidateTickCache ·
ServerLevel.advanceWeatherCycle · SleepStatus.areEnoughSleeping ·
ServerClockManager.moveToTimeMarker · ServerLevel.tickTime ·
LevelTicks.tick · ServerChunkCache.tick · ServerChunkCache.tickChunks ·
NaturalSpawner.createState · ServerLevel.tickChunk ·
ServerChunkCache.broadcastChangedChunks · ChunkHolder.broadcastChanges ·
ChunkMap.tick · ServerLevel.runBlockEvents · EntityTickList.forEach ·
Level.tickBlockEntities · PersistentEntitySectionManager.tick ·
LevelDebugSynchronizers.tick · RandomState.garbageCollect
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Players and sessions
Verified against Minecraft 26.3 · Part III · One connection, four events: a player joins, dies, walks into a Nether portal, and logs out.
A player clicks a server in the list, watches a progress bar, and is
standing in a world. An hour later they fall in lava, press Respawn, walk
through a portal and log off for the night. On the server those are four
events on one object graph, and they disagree about what survives. Dying
destroys the ServerPlayer and builds a new one; a trip to the Nether
keeps the same object and moves it. Both keep the player’s entity id, and
both keep the very same ServerGamePacketListenerImpl — a respawn does not
rebuild the connection, it repoints it, in one field assignment, at the new
player. Which is why anyone still holding the old ServerPlayer after a
respawn is holding a corpse: an object flagged removed, out of every list,
that will keep answering questions all day.
The cast
| class | what it decides | thread |
|---|---|---|
PlayerList | who is admitted, who is in the tab list, and the exact order of the join burst | Server |
ServerPlayer | one player’s world state, and what a new one inherits from an old one | Server |
ServerGamePacketListenerImpl | the session: the player it points at, the flush suspension, the load gate, the kicks | Netty to decode, Server to handle |
ServerConfigurationPacketListenerImpl | the strictly serial task queue a join is prepared in | Netty, with four handlers hopping to Server |
PrepareSpawnTask | where the player will stand, and when the ServerPlayer is finally constructed | Server, or Netty when accepting a code of conduct starts it |
PlayerDataStorage | the .dat file, its .dat_old twin and its corrupt-copy rescue | whichever thread starts the task, then Server |
PlayerChunkSender | how many chunks a client is trusted with, starting at one unacknowledged batch | Server |
CommonListenerCookie | the four facts that survive a phase change: profile, latency, client options, transferred | — |
The phases this trace passes through — login, configuration, play, and the
terminal packets between them — belong to
protocol phases. This page starts where
that one hands over: with PlayerList deciding whether the login is allowed
at all.
Admission is a Component or nothing
Identity here is a NameAndId: a record of a UUID and a name, made from the
authenticated GameProfile, and the key that the ban list, the whitelist, every
op lookup and every save file are addressed by (the IP ban list keys on the
address). UserNameToIdResolver,
reached through MinecraftServer.services, is the cache that maps between
the two halves — a CachedUserNameToIdResolver over usercache.json, with a
GameProfileRepository behind it for the lookups the file cannot answer — and
comparing its remembered name against the profile’s is how the server notices
a player has been renamed since their last visit.
Four questions, and the two ways past them
PlayerList.canPlayerLogin returns a Component — the reason to refuse,
rendered on the disconnect screen — or null for let them in. It asks
four questions in order — the ban list, the whitelist, the IP ban list, then
capacity — and the first three ask the same kind of object. A StoredUserList
is a JSON file of StoredUserEntry records keyed by identity (the IP bans by
address), rewritten whole
on every change, and there are four of them: UserBanList, IpBanList,
UserWhiteList and the ServerOpList that both surprises below turn on. The two
ban lists share a BanListEntry carrying a source, a reason and an expiry,
and nothing sweeps that expiry on a schedule: StoredUserList.get drops what
has lapsed before it answers, but the login gate asks UserBanList.isBanned
or IpBanList.isBanned first, which do not. So the first login after a
temporary ban lapses finds the ban, fetches an entry that is no longer there,
and throws; the server logs Internal server error and closes the connection,
and the sweep that caused it lets the next attempt in. The two ways past the gate are not the ones a reader expects.
The
whitelist is bypassed by being an op: PlayerList.isWhiteListed is
satisfied by presence in the op list, and DedicatedPlayerList.isWhiteListed
overrides it to route through PlayerList.isOp, which also asks whether
the identity is the singleplayer owner — a branch that never fires here,
because DedicatedServer.isSingleplayerOwner returns false for everyone.
The bypassesPlayerLimit flag in ops.json is a
separate thing entirely and reaches exactly one of the four questions: of
them, only the capacity test reads ServerOpListEntry.bypassesPlayerLimit,
through DedicatedPlayerList.canBypassPlayerLimit. A banned op is still banned.
The gate runs twice, and disagrees with itself
The gate runs twice, and the second run is not a repeat of the first.
ServerLoginPacketListenerImpl.verifyLoginAndFinishConnectionSetup runs it
during login; ServerConfigurationPacketListenerImpl.handleConfigurationFinished
runs it again when the client says configuration is over, because a ban or a
newly full server can land in the seconds a configuration takes. Duplicate
logins differ between the two. At login the newcomer wins:
PlayerList.disconnectAllPlayersWithProfile kicks every player holding
that UUID with PlayerList.DUPLICATE_LOGIN_DISCONNECT_MESSAGE, and the login parks until the old player has left the player list. At the second check the
newcomer loses — an existing player with that id is a flat rejection, since
by then there is a prepared spawn to throw away rather than a session to
evict.
Preparing a place to stand
sequenceDiagram
participant SLPL as ServerLoginPacket<br/>ListenerImpl
participant PL as PlayerList
participant SCPL as ServerConfiguration<br/>PacketListenerImpl
participant PST as PrepareSpawnTask
participant PDS as PlayerDataStorage
participant SL as ServerLevel
SLPL->>PL: canPlayerLogin, then disconnectAllPlayersWithProfile
SLPL->>SCPL: startConfiguration, on a listener just built
Note over SCPL: one task at a time, never two at once
SCPL->>SCPL: Synchronize<br/>RegistriesTask<br/>first, then the<br/>code of conduct<br/>or a resource pack
SCPL->>PST: start, appended by returnToWorld with a JoinWorldTask behind it
PST->>PL: loadPlayerData, for the saved position
PL->>PDS: load, the whole file read and datafixed
PST->>SL: a PLAYER_SPAWN ticket at radius 3, through its chunk source
rect rgba(0, 0, 0, 0.04)
Note over SCPL,SL: every tick until the chunks land, the client still in configuration
SL-->>PST: the load future completes, Preparing becomes Ready
end
SCPL->>SCPL: JoinWorldTask sends the finish packet
SCPL->>PL: the duplicate check again, then canPlayerLogin
SCPL->>PST: spawnPlayer
A join assembled while the client waits: the queue above the band runs one task at a time and never overlaps, and the band is where the waiting happens — a world being built for a client that has no idea.
Nothing in that queue overlaps.
ServerConfigurationPacketListenerImpl.startNextTask throws rather than
start a task while another is unfinished, so the registry transfer completes
before PrepareSpawnTask reads a byte, and the two optional tasks sit
between them. What does run alongside it is the chunk load and the
client’s wait: once the ticket is placed the task simply reports not
finished from ConfigurationTask.tick each tick, and the client spends
that time in configuration with no idea a world is being assembled for it.
The ticket needs re-arming, but not yet. TicketType.PLAYER_SPAWN is
registered with a timeout of twenty ticks and with TicketType.FLAG_LOADING as its only
flag, so TicketType.canExpireIfUnloaded is false and the timeout does not
begin while the chunks it asked for are still on their way — which is what
lets ServerChunkCache.addTicketAndLoadWithRadius accept this type at all,
since it throws for any type that could expire before it loads. From the
moment the task reaches PrepareSpawnTask.Ready the clock does run, and
PrepareSpawnTask.keepAlive — called from
ServerConfigurationPacketListenerImpl.tick — re-adds the ticket at
radius 3, the radius the figure shows, every tick. Without that, a client
slow to acknowledge the finish packet would arrive to find its spawn chunks
expired underneath it.
A player with no save file gets a search instead of a position, except on a
world whose game mode is Adventure, where the world spawn is used without a
search, only its height corrected.
PlayerSpawnFinder.findSpawn walks up to
PlayerSpawnFinder.ABSOLUTE_MAX_ATTEMPTS candidates — a thousand and
twenty-four, or fewer if GameRules.RESPAWN_RADIUS or the world border says
so — in a strided order from a random offset, loading each
candidate’s chunk under a one-tick TicketType.SPAWN_SEARCH ticket and
returning a future. The ChunkLoadCounter beside it feeds only the
server’s LevelLoadListener, through
LevelLoadListener.Stage.LOAD_PLAYER_CHUNKS — the client’s progress bar on
an integrated server, and nothing a remote player ever sees on a dedicated
one, where the listener is a LoggingLevelLoadListener that boot already
closed.
The save file is read twice, and both reads are the whole file
PrepareSpawnTask.start reads the .dat and decodes
ServerPlayer.SavedPosition from it — three optional fields, dimension,
position and rotation, and nothing else. That is all it decodes. It is not
all it does: PlayerDataStorage.load reads the compressed tag with an
unlimited accounter and runs DataFixTypes.PLAYER over the entire document
before any codec sees it (codecs, NBT and
JSON). So the narrow read saves decoding,
not I/O, and a join pays the migration cost twice — once here, and once in
PrepareSpawnTask.Ready, where the file is loaded again for the full
Entity.load.
Between the two reads, the player is built
ServerLevel.waitForEntities
blocks the Server thread until the entities in the spawn chunks have
finished loading, and then the ServerPlayer constructor runs, pulling its
ServerStatsCounter and PlayerAdvancements out of PlayerList on the way
past and applying the ClientInformation the client sent in configuration. The second read fills the
object in, the player is snapped to the prepared position,
PlayerList.placeNewPlayer runs, and only afterwards do
ServerPlayer.loadAndSpawnEnderPearls and
ServerPlayer.loadAndSpawnParentVehicle put back the ender pearls the player
had in flight and what they were sitting on when they left, both re-created
from the file.
Two rescues wired into the read
Two rescues are wired into the read, and the first is the file’s own.
PlayerDataStorage.load tries <uuid>.dat; if that comes back with
nothing — the file is missing, or NbtIo.readCompressed threw and logged
Failed to load player data — it copies whatever is there aside as
<uuid>corrupted<timestamp>.dat and then tries <uuid>.dat_old,
the twin the previous write rotated out. So a corrupted file is kept — under
the corrupted name, for whoever wants to look at it — and the player loses
what changed since the save before last rather than everything; a player with no readable file and no
twin is built from nothing, which is a new spawn rather than an error. Only
what survives that is datafixed.
The second rescue is about identity, and only an integrated server has it.
PlayerList.loadPlayerData checks whether the joining identity is the
singleplayer owner and, if the world
records a singleplayer_uuid in its level data, loads that file rather
than the one named for the joining id. It works for one session: the save writes under
the current id and MinecraftServer.saveAllChunks stamps the current
owner’s id into level.dat, so the next time the world is opened the old file
is orphaned.
PlayerList.placeNewPlayer sends a world in one write
sequenceDiagram
participant PL as PlayerList
participant SGPL as ServerGamePacket<br/>ListenerImpl
participant SL as ServerLevel
participant CM as ChunkMap
participant Wire as the network
Note over PL,Wire: in the packet drain at the top of a tick
PL->>SGPL: suspendFlushing, on a listener already set to play
PL->>SGPL: ClientboundLoginPacket, difficulty, abilities, held slot, recipes
PL->>SGPL: the permission level as an entity event, then the command tree
PL->>SGPL: recipe book, scoreboard, teleport, status
PL->>SGPL: the tab list as it stands, everyone already here
PL->>PL: the joiner is added to PlayerList.players
PL->>SGPL: the joiner, to everyone, themselves included
PL->>SGPL: the border, the clocks, the spawn, the rain, LEVEL_CHUNKS_LOAD_START, the tick rate
PL->>SL: addNewPlayer
SL->>CM: addEntity, then updatePlayerStatus and the first ChunkTrackingView
CM->>SGPL: every ready chunk in that view marked pending on PlayerChunkSender
PL->>SGPL: the boss bars, the effects, the inventory menu, then resumeFlushing
SGPL->>Wire: one write
Note over SL,Wire: later in the same tick, the first chunk batch, and only one
Everything PlayerList.placeNewPlayer sends the joiner, and the single write
at the foot that carries it; the self-message in the middle is the step the
tab-list order turns on.
The whole method sits inside one suspension, so everything above — a login
packet, a command tree, a scoreboard, a tab list, a world border — leaves as a
single write; what it tells the players already there goes out on their own
connections, one packet at a time. It is the same
ServerCommonPacketListenerImpl.suspendFlushing bracket the server
tick puts around every client
every tick, and the one place in the game it is opened by hand: a join is
handled in the packet drain that runs before
MinecraftServer.tickChildren opens the tick’s own.
ClientboundLoginPacket is where the client learns the entity id it is
about to be given, the hardcore flag, every dimension key the server has,
the view and simulation distances, whether the server authenticates, and a
CommonPlayerSpawnInfo from ServerPlayer.createCommonSpawnInfo carrying
the dimension type, the obfuscated seed, both game modes, the last death
location, the portal cooldown and the sea level. Three game rules are pinned
into it as plain booleans and never re-sent by this path:
GameRules.REDUCED_DEBUG_INFO, GameRules.LIMITED_CRAFTING, and
GameRules.IMMEDIATE_RESPAWN inverted into show the death screen.
The permission level arrives twice over, as a ClientboundEntityEventPacket
carrying one of five event ids and as the whole command tree from
Commands.sendCommands, both from PlayerList.sendPlayerPermissionLevel
resolving a LevelBasedPermissionSet out of
MinecraftServer.getProfilePermissions. Where such a set comes from is
permissions; all a join
needs to know is that both halves are re-sent whenever PlayerList.op or
PlayerList.deop changes it.
The tab list goes out in a deliberate order, and it is the middle step that
makes it one: the joiner is sent everyone already present, then added to
PlayerList.players, then everyone — themselves included — is sent the
joiner. Near the end of PlayerList.sendLevelInfo, with only the tick rate
after it, comes ClientboundGameEventPacket.LEVEL_CHUNKS_LOAD_START, the
client’s signal to stop waiting for the server and start waiting for its own
chunk. And the chat join message, which goes to the players already there and
not to the joiner, uses multiplayer.player.joined.renamed when the name in
the profile differs from the one the name cache remembered, which is why that
name is read at the top of the method, before the cache is overwritten.
Entering the level is the step that starts the terrain, though it is not
the last: the boss bars, the active effects, the inventory menu and
NotificationManager.playerJoined — which is not the chat message, sent
before the player entered the level — all follow it, and ServerCommonPacketListenerImpl.resumeFlushing
closes the single write.
ServerLevel.addNewPlayer hands the player to
PersistentEntitySectionManager.addNewEntity, whose callback adds it to
ServerLevel.players and to the chunk source; ChunkMap.updatePlayerStatus
registers the player with DistanceManager, resets its chunk tracking to
ChunkTrackingView.EMPTY and computes the real one, and every chunk inside
that view that is ready to send is marked pending on the connection’s
PlayerChunkSender. So
there are two player lists, written by different systems:
PlayerList.players is who is on the server and PlayerList alone writes
it, while ServerLevel.players is whose entity is in this level and only
the entity manager’s tracking callbacks write it. A player halfway through a
dimension change is in the first and in neither copy of the second.
A joining client is trusted with one batch, which is the only thing about
the chunk-sending loop that is a fact about joining: PlayerChunkSender
starts with a budget of a single unacknowledged batch, so the first batch a
player ever receives is a hard round trip before the second can leave. The
loop it then settles into — the client’s measured rate, the clamps and the
nearest-first order — belongs to what the client is
told,
and which chunks are in the player’s set at all, to tickets and
loading.
Loaded is something the client says
The player is in the world before the client can play.
ServerGamePacketListenerImpl.hasClientLoaded gates movement, block
breaking, item use, sprinting and sneaking, and it answers false until
either the client sends ServerboundPlayerLoadedPacket or a sixty-tick
timer runs out — ServerGamePacketListenerImpl.CLIENT_LOADED_TIMEOUT_TIME,
started in the listener’s constructor and counted down from
ServerPlayer.tick, which is to say from the level’s entity loop rather
than from the connection.
Death re-arms the same gate by a different mechanism.
ServerGamePacketListenerImpl.markClientUnloadedAfterDeath, called at the
end of ServerPlayer.die, sets a flag that no timer clears: a dead player’s
client counts as unloaded indefinitely, and only
ServerGamePacketListenerImpl.restartClientLoadTimerAfterRespawn — reached
from the respawn, or from a brand-new listener — clears the flag and starts
the sixty ticks again. The death screen is held open by the same gate as the
Joining world screen, through a different field.
That the countdown ticks from ServerPlayer.tick while the food, health and
stat sync tick from ServerPlayer.doTick is the whole reason a player is
ticked from two places every tick; the two-phase
tick is
the lecture on that. The fact worth
carrying out of here is that the level’s entity loop ticks players
regardless of entity-ticking range.
Four ways the session changes
The join is one story; what happens afterwards is a comparison. Three of these a player will meet tonight and the fourth exists for one debug command, and they disagree about almost everything.
| respawn | dimension change | disconnect | ServerGamePacketListenerImpl.switchToConfig | |
|---|---|---|---|---|
| who runs it | ServerGamePacketListenerImpl.handleClientCommand | ServerPlayer.teleport | Connection.handleDisconnection | DebugConfigCommand |
same ServerPlayer? | no — PlayerList.respawn constructs a new one | yes | yes, briefly | yes, then a new one on the way back |
| same entity id? | yes, copied over by Entity.setId | yes | — | no, the rebuilt player gets a fresh one |
| same connection listener? | yes, ServerPlayer.connection is reassigned | yes | — | no, a new one from PlayerList.placeNewPlayer |
| what carries over | whatever ServerPlayer.restoreFrom copies | everything, because nothing is copied | — | whatever the .dat holds |
is the .dat written? | no | no | yes, inside PlayerList.remove | yes, the same call |
| the client is told | ClientboundRespawnPacket, keeping nothing, or attribute modifiers | ClientboundRespawnPacket, ClientboundRespawnPacket.KEEP_ALL_DATA | nothing, the channel is already gone | ClientboundStartConfigurationPacket |
| removal reason | Entity.RemovalReason.KILLED | Entity.RemovalReason.CHANGED_DIMENSION | Entity.RemovalReason.UNLOADED_WITH_PLAYER | the same |
The object, and the reference that outlives it
PlayerList.respawn builds the new ServerPlayer from the old one’s
profile and client information, then performs three assignments that keep
the difference out of every tab list: ServerPlayer.connection is set to
the old player’s listener, Entity.setId copies the entity id, and the
listener’s own player field is reassigned by its caller. To every other
client’s tab list nothing has happened — same id, same UUID, same row — though
entity tracking removes the old body and adds the new one, and a hardcore
respawn sends the row’s new game mode. To
anything inside the server holding the old object, everything has happened:
it was removed from its level before the new one existed, and it is not in
PlayerList.players any more.
Only two of the four paths take a TeleportTransition, and they are the two
that move you. ServerPlayer.findRespawnPositionAndUseSpawnBlock turns the
saved ServerPlayer.RespawnConfig — respawn data plus a forced flag —
into one: a respawn anchor is found and a charge spent unless the point is
forced, a bed is found and costs nothing,
TeleportTransition.missingRespawnBlock says the block is gone, and
TeleportTransition.createDefault falls back to the world spawn. Both of
those last two are the expensive branch, not just the fallback: each builds
its position with TeleportTransition.findAdjustedSharedSpawnPos, so each
runs ServerPlayer.adjustSpawnLocation — the same PlayerSpawnFinder search
a join runs asynchronously — and blocks the Server thread on the future with
BlockableEventLoop.managedBlock.
What comes across when you die
ServerPlayer.restoreFrom has two branches, and the interesting one is not
the one people name it for. Its restore everything branch — permanent
attribute modifiers, health, hunger, every active effect, the inventory, the
portal state — is reached only when PlayerList.respawn is called with
Entity.RemovalReason.CHANGED_DIMENSION, which happens in exactly one place:
a player leaving the end credits, with ServerPlayer.wonGame
set. That is the End-portal return, not keepInventory.
Which makes the end credits a fifth way out of a ServerLevel, and the
reason the table above has four columns rather than five:
ServerPlayer.showEndCredits removes the player with
Entity.RemovalReason.CHANGED_DIMENSION like a dimension change, and the
client, when the credits end or are closed, sends the respawn that hands it to
PlayerList.respawn like a death. It is not a way a session
changes so much as the one place two of them meet.
An ordinary death takes the other branch. Health is reset to maximum,
effects are gone, and GameRules.KEEP_INVENTORY — or having died as a
spectator — decides only whether ServerPlayer.restoreFrom runs
ServerPlayer.transferInventoryXpAndScore, which moves the inventory, the
experience and the score and nothing else. What the same rule decided on the
way out, when the old player died, belongs to damage and
death.
Outside the branch, and so true of every death, a long tail is copied
unconditionally: the ender chest, the enchantment seed, both game modes,
base attribute values, the recipe book, the warden spawn tracker, the chat
session, the skin customisation, the last death location. The ender chest
surviving death is not a game rule; it is a field assignment.
Why the Nether keeps your potion effects
Because nothing is copied. ServerPlayer.teleport across a dimension
boundary sets ServerPlayer.isChangingDimension, sends the client a
ClientboundRespawnPacket marked ClientboundRespawnPacket.KEEP_ALL_DATA,
removes the entity from the old level with
Entity.RemovalReason.CHANGED_DIMENSION — one of the two reasons that
neither destroy an entity nor write it into a chunk (entity
lifecycle has the
five) — immediately calls
Entity.unsetRemoved, points it at the new level with
ServerPlayer.setServerLevel, and adds it back with
ServerLevel.addDuringTeleport. It is one object the whole way. The effects
survive because they were never touched, and what the server then re-sends
— level info, the inventory through PlayerList.sendAllPlayerInfo, the
active effects, the abilities, the permission level — is not a restoration
but a re-sync, because the client threw its own world away the moment
the respawn packet arrived.
A same-dimension teleport is not this path at all. It is
ServerGamePacketListenerImpl.teleport followed by
ServerGamePacketListenerImpl.resetPosition, with no client-side world
discarded and no respawn packet sent.
Where your llama goes when you log out
The channel closes on a Netty thread and nothing happens until
ServerConnectionListener.tick, in the connection step of the server tick,
notices and calls Connection.handleDisconnection (the
connection is the
channel’s own account of dying). That reaches
ServerGamePacketListenerImpl.removePlayerFromWorld: the leave message,
ServerPlayer.disconnect to eject passengers and stop sleeping, and
PlayerList.remove.
The order inside PlayerList.remove is the answer to the question. The
.dat is written first, with the stats and advancements JSON beside it,
and ServerPlayer.saveParentVehicle writes the entire root vehicle into it
under RootVehicle — but only if that vehicle has exactly one player
passenger. The same test then decides whether the vehicle chain is removed
from the world with Entity.RemovalReason.UNLOADED_WITH_PLAYER — the other
of those two non-destroying reasons, which is exactly what you want for
something already written into a different file.
In-flight ender pearls go the same way, saved into the file and removed from
the world. So the llama leaves the world with you, rides in your save, and
is put back by ServerPlayer.loadAndSpawnParentVehicle after your next
PlayerList.placeNewPlayer — unless somebody else was riding it too, in
which case it stays where it is and you land beside it. On an integrated
server one further thing happens:
ServerCommonPacketListenerImpl.onDisconnect sees that the leaver is the
singleplayer owner and halts the server (how a server
dies, which owns PlayerList.saveAll and
PlayerList.removeAll at shutdown).
ServerGamePacketListenerImpl.switchToConfig is that same removal with the
connection left alive. It runs ServerGamePacketListenerImpl.removePlayerFromWorld — leave message,
saved file, tab-list removal and all — which is the half that belongs here:
as far as this page’s four ways are concerned, a reconfigure is a leave
that keeps the socket. What the connection then does with the phase, and why
no registry is re-sent on the way back, belong to protocol
phases. CommonListenerCookie carries the client’s options and the transferred
flag across that return, as it does across every phase change.
What everyone else is told
Very little, and nearly all of it on the way out. A respawn and a dimension change send
other clients nothing for the tab list, a hardcore respawn’s game mode aside,
because the identity a tab list is keyed on never changed. A disconnect sends one
ClientboundPlayerInfoRemovePacket to everybody, and otherwise a tab list
hears a row’s game mode, hat or chat session when one changes and the latency
sweep PlayerList.tick broadcasts on its own slow counter (the server
tick).
The rest of what other players see is entity tracking in ChunkMap, and the
removal reasons in the table above have already told it what to do.
The three kicks that come from the tick
ServerGamePacketListenerImpl.tick ends sessions of its own accord. An idle
player is disconnected after MinecraftServer.playerIdleTimeout minutes
with no action, unless they are sitting on the end credits. A player whose
client reports itself airborne for too long is kicked for flying, on a budget
that stretches as gravity falls and against a list of exemptions the movement
page owns (input to
movement). And
keep-alive disconnects a client that has stopped answering — a mechanism that
belongs to the connection rather than to the session, because it runs in the
configuration phase too (the
connection).
The asymmetry is what this section is about. The singleplayer owner is
exempt from the keep-alive, the one kick of the three that could reach it:
the keep-alive alone asks
ServerCommonPacketListenerImpl.isSingleplayerOwner, and the host exists only
on an integrated server, which allows flight and sets no idle timeout, so
nobody there is kicked for either of the others.
Where to look
PlayerList.canPlayerLogin · PlayerList.placeNewPlayer ·
PlayerList.respawn · PlayerList.remove · PlayerList.loadPlayerData ·
PlayerList.sendLevelInfo · PrepareSpawnTask · JoinWorldTask ·
PlayerSpawnFinder · PlayerDataStorage · ServerPlayer.restoreFrom ·
ServerPlayer.teleport ·
ServerPlayer.findRespawnPositionAndUseSpawnBlock · ServerPlayer.die ·
ServerPlayer.doTick ·
ServerGamePacketListenerImpl.removePlayerFromWorld ·
ServerGamePacketListenerImpl.switchToConfig ·
ServerGamePacketListenerImpl.hasClientLoaded ·
ServerCommonPacketListenerImpl · PlayerChunkSender ·
ChunkMap.updatePlayerStatus · TeleportTransition
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Starting a server
Verified against Minecraft 26.3 · Part III · Dropping the jar in an empty folder, typing java -jar server.jar, and waiting for the line that says Done.
The first run unpacks the libraries the jar carries, writes two files and
exits: you have not agreed to the EULA.
The second gets as far as Preparing level “world” and, a second or two
later, Done (1.284s)! For help, type “help”. By the time the second of
those lines prints, the server has opened a data pack stack, built every
registry the world is made of, taken an operating-system lock on the save
directory, rewritten level.dat, constructed a ServerLevel for every
dimension the packs declare and started listening on 25565 — and only the
levels were built between the two lines. Every other item on that list was
over before the first one printed, which is why the elapsed time Done
reports is so short. It is not a claim about the whole boot: query, RCON, the
watchdog and JMX are all started after that line, and the last of the
server’s own start-up work happens later still. And the step in the middle
whose name promises the most —
MinecraftServer.prepareLevels, the one whose progress line still reads
Preparing spawn area — does the least: on an ordinary world it loads no
chunks at all. All it does is re-arm the chunk-loading tickets the last
shutdown wrote down (tickets and
loading has the nine
kinds), and exactly two of the nine are ever written down.
The cast
| class | what it decides | thread |
|---|---|---|
server/Main | everything decided before the Server thread exists: the flags, the properties, the EULA, which directory is the world, and when to hand off | ServerMain, which the jar’s bundler starts |
DedicatedServerSettings | the live copy of server.properties — and the rewrite that happens every time a setting changes | ServerMain, then Server |
LevelStorageSource.LevelStorageAccess | one open world: its directory layout, its session.lock, and every read and write of level.dat while the world is open | whoever holds it |
WorldLoader | the packs, the registries and the datapack-driven resources, assembled into a WorldStem | ServerMain and Util.backgroundExecutor |
DedicatedServer | what a dedicated server has and singleplayer does not: the console, the port, the legacy conversions, RCON, query, the watchdog | Server, but constructed on ServerMain |
MinecraftServer | the levels, the saves and the loop — and MinecraftServer.spin, the line where the Server thread begins | Server |
ServerLevel | one dimension: its chunk source, its saved data, its ticket store | Server |
LevelLoadListener | what boot progress looks like: log lines on a dedicated server, a progress bar on a client | the caller’s |
From the command line to the Server thread
sequenceDiagram
participant Main as Main
participant LSA as LevelStorageSource.<br/>LevelStorageAccess
participant WL as WorldLoader
participant Worker as Worker
participant DS as DedicatedServer
Main->>Main: the flags, Bootstrap.bootStrap, server.properties, the EULA gate
Main->>Main: JsonRpc.create, if the management server is enabled at all
Main->>LSA: validateAndCreateAccess, taking session.lock in the constructor
Main->>LSA: getUnfixedDataTagWithFallback, if there is a world: level.dat, or level.dat_old restored
Main->>WL: load, wrapped in Util.blockUntilDone, so this thread is an executor
WL->>Main: the pack-opening stage, the reload's apply steps and the final assembly, single-threaded
WL->>Worker: static tags, world then dimension registries, the resources
Worker-->>WL: the background stages, finished
WL-->>Main: a WorldStem
Main->>LSA: saveDataTag rewrites level.dat now, upgrade or no upgrade
Main->>DS: MinecraftServer.spin runs the factory here, on this thread
Note over Main,DS: the thread starts only now, and main returns
The first half of the boot, and the wall at the foot of it: one thread drives every line above that note, with the worker pool’s help, and the server object does not exist for most of them.
Read the note bar as a wall. Above it one thread drives the work, handing the load’s background stages to the worker pool; below it main has returned, and everything that remains is the Server thread and the daemons arranged around it — the half drawn under The Server thread wakes up below. Anatomy draws the same hand-off from the client’s side, where the thread that spins the server is the one drawing frames.
Everything main does before there is a second thread
Main.main does not run on the thread the JVM started with: the jar opens
with a bundler, which unpacks the libraries and the game’s own jar into the
working directory and starts Main.main from that copy on a thread named
ServerMain, and that thread stays with it for all of what
follows. SharedConstants.tryDetectVersion reads version.json out of
the jar first, so everything downstream knows what version it is. Then the
flags are parsed — –nogui, –port, –universe, –world,
–forceUpgrade, –recreateRegionFiles, –safeMode, –initSettings,
–pidFile and the rest — and CrashReport.preload runs: MemoryReserve
sets a block of heap aside and one throwaway report is formatted and
discarded, so that the crash-report path is warm and has memory of its own
even when the reason for the crash is that there is none left.
Bootstrap.bootStrap and Bootstrap.validate build and freeze the static
registries (identifiers and
registries),
and Util.startTimerHackThread starts a daemon that sleeps forever and
touches nothing. The last thing Bootstrap.bootStrap does is quieter and
matters later: it puts a LoggedPrintStream over System.out and
System.err — a DebugLoggedPrintStream when debug logging is on — so that
anything printed from here on goes through the logger, and the original
stream survives only as Bootstrap.STDOUT. That is the stream a crash report
uses when the logger cannot be trusted (how a server
dies).
Only then does the server read its own configuration. DedicatedServerSettings
parses server.properties, and DedicatedServerSettings.forceSave writes it
straight back — which is why a properties file carried over from an older jar
comes back with the new keys filled in. RegionFileVersion.configure takes
the region compression out of it before any chunk file is ever opened
(chunk storage). And
Eula reads eula.txt, or writes the default and reports it missing. That is
the gate: Eula.hasAgreedToEULA false means one log line and main returns.
No world has been opened, no server exists, and everything main has started
so far is a daemon — which does not hold the JVM open, so the process ends
with the return. –initSettings returns one step earlier still, having
written both files on purpose.
The management port opens before the world does
With the EULA agreed, Services.create builds the authentication services and
the name cache in the universe directory, and JsonRpc.create starts a
ManagementServer if management-server-enabled is set — and throws, ending
the boot, if the secret it finds is not forty alphanumeric characters. A
missing secret is not that failure: DedicatedServerProperties resolves
management-server-secret to a freshly generated key when the file has none,
and the properties rewrite above puts that key in the file — so what stops a
boot is a malformed secret, not an absent one. TLS stops it too: it is on by
default and wants a keystore, which by default is not configured, so enabling
the management server and nothing else ends the boot. What starts is a Netty
WebSocket listener with its own event-loop group named Management server
IO, TLS on by default, and a
JsonRpcNotificationService registered on the NotificationManager that
everything later in the boot reports through. It binds before session.lock
is taken, and DedicatedServer.onServerExit stops it last, so the management
protocol is reachable at both ends of the server’s life — including while
there is no world to ask it about (how a server dies).
Taking the lock, and fixing level.dat twice
LevelStorageSource.createDefault points at the universe directory — the
working directory unless –universe says otherwise — and
LevelStorageSource.validateAndCreateAccess opens the world named by
–world or by level-name. The validation is a symlink check; the lock is
neither optional nor deferred. DirectoryLock.create, called from the
LevelStorageSource.LevelStorageAccess constructor, opens
DirectoryLock.LOCK_FILE, writes a snowman into it and takes a
FileChannel.tryLock on it. That is an operating-system advisory lock, not a
value in the file: a second server on the same directory fails at once with
DirectoryLock.LockException, a crashed JVM releases it with nothing to clean
up, and a world folder copied out from under a running server carries a
session.lock that means nothing. The client’s world list greys out a world
that DirectoryLock.isLocked reports held.
If the directory already holds world data, main parses level.dat once and
runs the result through the datafixers twice.
LevelStorageSource.LevelStorageAccess.getUnfixedDataTagWithFallback reads
the file and, on a parse failure, falls back to level.dat_old and restores
it over the original. That raw tag goes through
LevelStorageSource.LevelStorageAccess.fixAndGetSummaryFromTag for a
LevelSummary, which is enough to answer
LevelSummary.requiresManualConversion and LevelSummary.isCompatible — two
gates that each end the boot with one explanatory line — and separately
through DataFixers.getFileFixer for the fully upgraded tag the world is
built from.
The world load turns the main thread into an executor
ServerPacksSource.createPackRepository builds the repository over the
world’s datapacks/ folder and WorldLoader.load does the rest: the staged
load that the resource system describes,
run here for server data. WorldLoader.PackConfig.createResourceManager
selects and opens the packs, TagLoader.loadTagsForExistingRegistries
collects tags for the static registries, RegistryDataLoader loads
RegistryDataLoader.WORLD_REGISTRIES and then
RegistryDataLoader.DIMENSION_REGISTRIES, the WorldLoader.WorldDataSupplier
turns the fixed level.dat into a PrimaryLevelData through
LevelStorageSource.getLevelDataAndDimensions — or, with no world data at
all, Main.createNewWorldData builds one out of server.properties. Last,
ReloadableServerResources.loadResources loads the rest:
RegistryDataLoader.RELOADABLE_REGISTRIES, the loot tables, recipes and
advancements among them, through ReloadableServerRegistries.reload, and then
the functions. What comes back is a WorldStem.
The threading is the part worth noticing. WorldLoader.load takes two
executors: Util.backgroundExecutor for the work, and a main-thread executor
for the stages that must be single-threaded. Util.blockUntilDone supplies
the second by handing the loader a queue’s add method and then draining that
queue until the future completes. For the length of the world load
ServerMain is an event loop, running the pack-opening stage, the reload’s
apply steps and the final assembly itself between bouts of waiting on the
workers.
Two things then happen before the server object exists. With –forceUpgrade
or –recreateRegionFiles, a WorldUpgrader rewrites the chunks that need it (an older version’s, those
holding a cache –eraseCache clears, or every chunk with
–recreateRegionFiles) while main polls it once a second and logs
a percentage. And either way
LevelStorageSource.LevelStorageAccess.saveDataTag writes level.dat back
out, by the same replace every later save uses (level data and
rules).
That is unconditional: a server started and killed one second later has
already rewritten its world data.
MinecraftServer.spin, and the last thing main does
MinecraftServer.spin takes a factory rather than a server, and its order is
the point. It builds the Thread object first, sets priority 8 on a machine
with more than four processors, calls the factory on the calling thread, and
only then starts the thread — so the whole of the DedicatedServer
constructor runs on ServerMain, and the new thread cannot begin
before there is a server for it to run.
That constructor is real work. MinecraftServer’s own opens the server-wide
SavedDataStorage, takes the WorldData and WorldGenSettings out of the
stem (creating a world is where the stem
was built, by a screen or by server.properties), builds the ServerConnectionListener, the PlayerDataStorage, the
GameRules, the StructureTemplateManager and the PacketProcessor,
finalises recipe loading, and refuses outright a stem whose LevelStem
registry has no overworld. DedicatedServer’s adds the ServerTextFilter,
the ServerLinks built from the bug-report property, and — when
enable-code-of-conduct is set — every .txt file under the codeofconduct
folder, which throws if that folder is missing. A misconfigured code of
conduct kills the server on ServerMain, before a Server thread exists to
be killed.
Back in main, the factory has already applied –port, –demo and
–serverId and, unless –nogui or a headless JVM says otherwise, opened
the Swing window through DedicatedServer.showGui. A Server Shutdown Thread
hook goes on the runtime — its whole body is
one MinecraftServer.halt call — and main returns.
The Server thread wakes up, and can still fail twice
sequenceDiagram
participant DS as DedicatedServer
participant MS as MinecraftServer
participant SL as ServerLevel
Note over DS,SL: the other side of the wall: the Server thread
MS->>DS: runServer calls initServer
DS->>DS: the console daemon thread, the properties into fields, the key pair
DS->>DS: startTcpServerListener binds the port, then convertOldUsers
DS->>MS: loadLevel, which is createLevels, forceDifficulty and prepareLevels
MS->>SL: the overworld first, then one level per LevelStem on DerivedLevelData
MS->>SL: the persisted tickets re-armed, then a wait in 10 ms slices
SL-->>MS: on an ordinary world, nothing pending: nothing was asked for
MS-->>DS: loadLevel returns
DS->>DS: Done is logged here, before the loop is ever entered
DS->>DS: the optional listeners, each if its property is set
DS-->>MS: initServer returns true
MS->>MS: the icon and the status response, then the tick loop
The second half: the play port is bound two messages before the levels exist, and the word Done is logged three messages before the loop it claims is running.
MinecraftServer.runServer is the Server thread’s body and its first act is
DedicatedServer.initServer, which begins with the console: a daemon thread
named Server console handler reading System.in line by line. It runs
nothing itself. Each line becomes a ConsoleInput — the text plus a
CommandSourceStack built there on the console thread — appended to a
synchronized list that DedicatedServer.handleConsoleInputs drains from
DedicatedServer.tickConnection, so a typed command executes inside a tick,
in its connection phase.
Then the properties become fields: online mode, the local IP, the default game
type, the port. MinecraftServer.initializeKeyPair generates the RSA pair that
login encryption uses (protocol
phases), and
ServerConnectionListener.startTcpServerListener binds the port with a Netty
server bootstrap. Two things can end the boot at this point, and they end it
identically.
| the failure | what the console says | the check |
|---|---|---|
| the port is already taken | FAILED TO BIND TO PORT! and the exception | ServerConnectionListener.startTcpServerListener throws, and DedicatedServer.initServer returns false |
| a legacy user list survived conversion | FAILED TO START THE SERVER AFTER ACCOUNT CONVERSION! and the files to delete by hand | OldUsersConverter.areOldUserlistsRemoved looks for banned-players.txt, banned-ips.txt, ops.txt and white-list.txt, and is false if any of the four is still there |
The conversion itself is DedicatedServer.convertOldUsers, which attempts
five migrations — the two ban lists, the op list, the whitelist and the player
save files — retrying a failed one up to twice more, five seconds apart, and reporting
true unless all five failed; a missing legacy file counts as a success, so an
ordinary server reports true on every boot. The gate is the separate check above, so
what stops the boot is the file nobody could convert. Either failure returns
false out of DedicatedServer.initServer, and a false there is not a quiet
exit: MinecraftServer.runServer calls it inside its own try and
throws an IllegalStateException on false, so the boot failure lands in the
loop’s own catch, writes a crash report and falls into the same finally that
/stop reaches —
a server that failed to bind still walks the whole shutdown path and releases
session.lock on the way out (how a server dies).
Past them, a DedicatedPlayerList is built, offline names are resolved in the
cache, and the log says Preparing level “world”.
Building the levels
MinecraftServer.loadLevel is three calls: MinecraftServer.createLevels,
MinecraftServer.forceDifficulty — empty in the base class, and overridden
only by DedicatedServer.forceDifficulty, which pushes server.properties’
difficulty onto the world on every boot — and
MinecraftServer.prepareLevels.
MinecraftServer.createLevels builds the overworld first and by name, and it
is the only level that is special. It gets the custom spawners — the
PhantomSpawner, the PatrolSpawner, the CatSpawner, the VillageSiege
and the WanderingTraderSpawner — and its ServerLevelData is the real
PrimaryLevelData out of level.dat. With it in MinecraftServer.levels (a
LinkedHashMap, so it stays first for every later walk over the levels), the
scoreboard, the CommandStorage and the Stopwatches come out of the
server-wide saved data. Every other LevelStem in the registry then gets a
ServerLevel over a DerivedLevelData, which forwards the overworld’s game
time, difficulty, game mode and hardcore flag and swallows every setter but
the spawn. That is why the difficulty is one number every dimension shares.
The other world-wide numbers a reader would expect to find here are not its
doing: the time of day belongs to ServerClockManager, the weather to one
server-wide WeatherData, and the spawn a level reports to
MinecraftServer.effectiveRespawnData
(level data and rules).
A brand-new world takes one detour, as soon as the overworld exists and
before the other dimensions do, and it is the detour that actually generates
terrain at boot. When ServerLevelData.isInitialized is false,
MinecraftServer.setInitialSpawn asks the generator for its origin chunk
(ChunkGenerator.getOrigin), reads the generator’s spawn height, and walks a
spiral over the chunks five in each direction — the value
MinecraftServer.SPAWN_POSITION_SEARCH_RADIUS names — calling PlayerSpawnFinder.getSpawnPosInChunk until one of them offers a
standable block. The bonus chest is placed here if –bonusChest asked for
one. Then the flag is set, and no later boot of that world looks for a spawn again.
Preparing the levels, which prepares nothing
MinecraftServer.prepareLevels replays a list. For each level a
ChunkLoadCounter records which chunks are already ChunkStatus.FULL, calls
TicketStorage.activateAllDeactivatedTickets, runs the distance manager again
and counts what is new. The tickets it replays are the persisting ones the last shutdown parked rather than dropped, and two of the nine TicketTypes carry
TicketType.FLAG_PERSIST — TicketType.FORCED, from /forceload, and
TicketType.PORTAL — so those two are the only entries a chunk_tickets file
contains (tickets and
loading has the nine).
The world spawn is kept by nothing.
Zero chunks are loaded by MinecraftServer.prepareLevels on a world with
no forceloads and no live portal ticket.
What follows is MinecraftServer.waitUntilNextTick with the deadline set
MinecraftServer.PREPARE_LEVELS_DEFAULT_DELAY_NANOS — 10 ms — out, repeated
while ChunkLoadCounter.pendingChunks is above zero: the Server thread
pumping its own queue in slices, the same way a tick waits for a chunk
(the server tick). With a total of zero it runs once and
leaves. MinecraftServer.updateMobSpawningFlags and the effective respawn
data are recomputed, and the step is over.
What the console prints while nothing is prepared
The output is worth reading literally. LoggingLevelLoadListener logs
Loading N persistent chunks… when
LevelLoadListener.Stage.LOAD_INITIAL_CHUNKS starts and Time elapsed: N ms
when it finishes, with Preparing spawn area: N% — still the
menu.preparingSpawn string, its percentage computed by
LevelLoadProgressTracker — printed at most twice a second in between. On an
ordinary world there is no in between: N is zero, and the percentage line
prints at most once, on the loop’s single pass. Of the four values LevelLoadListener.Stage declares,
LevelLoadListener.Stage.PREPARE_GLOBAL_SPAWN fires only on a world’s first
boot, LevelLoadListener.Stage.LOAD_PLAYER_CHUNKS belongs to a player joining
rather than to boot — PrepareSpawnTask, in the configuration phase
(players and sessions) — and
LevelLoadListener.Stage.START_SERVER is declared and fired by nothing.
Done comes before the loop
DedicatedServer.initServer logs Done (1.284s)! For help, type “help” the
moment MinecraftServer.loadLevel returns — and only then starts the optional
listeners. QueryThreadGs4.create runs if enable-query, RconThread.create
if enable-rcon, a Server Watchdog thread if
DedicatedServer.getMaxTickLength is above zero, JMX if
enable-jmx-monitoring. After those come one MinecraftServer.saveEverything
with flush and force and NotificationManager.serverStarted on the JSON-RPC
feed. DedicatedServer.initServer returns true, MinecraftServer.runServer
loads server-icon.png — the server directory’s, or failing that the world’s,
and it must be 64 by 64 — builds the first ServerStatus, and enters the tick
loop.
So Done is a claim about the world being ready rather than about the server
being reachable in every sense. The play port has been open since before the
level loaded, and RCON is still not listening when the line prints.
MinecraftServer.isReady — read by the singleplayer client’s loading screen
and by the JSON-RPC status method — is set at the bottom of the loop’s first
iteration, one tick later still.
The threads startup leaves behind
Boot creates every thread on this list and then hands the process to one of them; the column that matters here is daemon, because it decides which of them can hold the JVM open. What each may touch is the thread reference’s, which has the complete set including the pools and the situational ones (threads).
| thread | made by | daemon | what it may touch |
|---|---|---|---|
| Server thread | MinecraftServer.spin, from main | no | everything — it is the only thread allowed to change the world |
| Server console handler | DedicatedServer.initServer, first statement | yes | System.in and the ConsoleInput list. The command itself runs on the Server thread |
| Server Watchdog | DedicatedServer.initServer, only if DedicatedServer.getMaxTickLength is above zero | yes | reads MinecraftServer.getNextTickTime, the game rules and ServerLevel.getWatchdogStats off-thread, mid-tick |
| RCON Listener, plus one RCON Client per connection | RconThread.create, after Done | no | TCP accept. DedicatedServer.runCommand hops the command onto the Server thread with BlockableEventLoop.executeBlocking |
| Query Listener | QueryThreadGs4.create, after Done | no | a UDP status protocol, read-only |
| Management server IO | JsonRpc.create, back in main | yes | the JSON-RPC socket. ManagementServer.tick runs from DedicatedServer.tickServer |
| Timer hack thread | Util.startTimerHackThread, in main | yes | nothing. It sleeps, and nothing wakes it |
Two rows carry a consequence for the other end of the story. RconThread and
QueryThreadGs4 are the only non-daemon threads in this table besides the
Server thread itself — Util.ioPool’s IO-Worker threads, made outside it and
squarely in the boot path, are non-daemon too — so boot is where the process
acquires the three things that will later have to be stopped by hand rather
than abandoned, and what that costs at the other end belongs to how a server
dies. Why those two poll,
and the conditions that can leave RCON unstarted with enable-rcon set, are
per-thread facts and live with the rest of them
(threads).
One more thing outlives boot without being a thread: server.properties stays
live. Nineteen DedicatedServerProperties fields are Settings.MutableValues,
and Settings.MutableValue.update rebuilds the whole properties object and
DedicatedServerSettings.update writes the file back, so /whitelist,
the spawn-protection setter and the JSON-RPC settings calls all edit
server.properties on disk while the server runs. /difficulty does not: it
changes the world, and DedicatedServer.forceDifficulty puts the file’s
difficulty back at the next boot.
Singleplayer boots the same server with a shorter list
IntegratedServer uses the same MinecraftServer.spin,
MinecraftServer.runServer and MinecraftServer.loadLevel, and almost
nothing else on this page. IntegratedServer.initServer turns authentication
on, generates the key pair, calls MinecraftServer.loadLevel, sets the MOTD
from the host’s name and the level name, saves once and returns true: no
console thread, no TCP bind, no legacy conversion, no RCON, no query, no
watchdog and no Done line. Its player list is built in the constructor
rather than in DedicatedServer.initServer, and its LevelLoadListener is a
LevelLoadTracker composed with the logging one, so the client’s progress bar
and a dedicated server’s log lines are two readings of the same callbacks. The
whole of it is constructed from Minecraft.doWorldLoad on the Render thread,
by a caller that goes back to drawing frames rather than returning from
main — anatomy is that hand-off, and its diagram is
the one to read for how the two loops meet.
For a 1.21-era reader. There is no spawnChunkRadius game rule:
GameRuleRegistryFixdeletes it out of any save that still carries one, and the chunks around the world spawn are kept alive by whoever stands in them and by nothing else. spawn-protection inserver.propertiessurvives and is unrelated — it is a permission check inDedicatedServer.isUnderSpawnProtection, not a loader.
Where to look
In boot order. server/Main · Bootstrap.bootStrap · Eula ·
DedicatedServerSettings · JsonRpc.create ·
LevelStorageSource.validateAndCreateAccess ·
LevelStorageSource.LevelStorageAccess · DirectoryLock.create ·
WorldLoader.load · Util.blockUntilDone · WorldStem ·
MinecraftServer.spin · MinecraftServer.runServer ·
DedicatedServer.initServer · MinecraftServer.loadLevel ·
MinecraftServer.createLevels · MinecraftServer.prepareLevels ·
LevelLoadListener · IntegratedServer.initServer
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
How a server dies
Verified against Minecraft 26.3 · Part III ·
/stoptyped at the console, an exception out of the tick loop, and a tick that never ends — three endings, and what each writes of your world to disk.
An admin types /stop. The command sets one boolean and returns, and the
lap already in progress carries on to its end. Everything a player would
call shutting down — the players written, the unloads drained, level.dat
rotated, session.lock released — happens afterwards, inside the finally
of the loop that just exited, on the same Server thread that was ticking
mobs a moment ago. Which is what makes the second ending strange, and worth
a lecture: a crash in the tick saves your world and the watchdog does not. An
exception out of the tick loop lands in that same finally, so a server
that dies of a bad block entity writes exactly what /stop writes.
ServerWatchdog instead calls System.exit, which runs the Server Shutdown
Thread hook, which calls MinecraftServer.halt with wait true and waits
for the Server thread to finish — the very thread wedged in the tick that
tripped the watchdog. That wait never returns. Ten seconds later the
watchdog’s own scheduled Runtime.halt ends the JVM with no save run.
Three endings, side by side
/stop | a crash in the tick | a watchdog kill | |
|---|---|---|---|
what clears MinecraftServer.running | MinecraftServer.halt, called by StopCommand with wait false and by five other callers below | nothing: the loop is left by the throw, not by the condition | the shutdown hook, eventually — System.exit runs it and it calls MinecraftServer.halt with wait true |
does MinecraftServer.runServer’s finally run | yes, on the Server thread | yes, on the Server thread, after the crash report | no: the Server thread never leaves the tick |
| are players saved | yes, PlayerList.saveAll then PlayerList.removeAll | yes, identically | no |
| are chunks saved | yes: the unload drain, then MinecraftServer.saveAllChunks with flush | yes, identically | no — only what an autosave, an unload or the eager sweep had already written |
is level.dat written | yes, LevelStorageSource.LevelStorageAccess.saveDataTag | yes, identically | no |
is session.lock released | yes, LevelStorageSource.LevelStorageAccess.close drops the DirectoryLock | yes, identically | not by the game — the OS drops it when the process dies |
| is a crash report written | no | yes, into crash-reports/, from MinecraftServer.constructOrExtractCrashReport | yes, into crash-reports/, from ServerWatchdog.createWatchdogCrashReport, before the exit |
| what ends the JVM | nothing explicit: the Server thread returns and no non-daemon thread is left | the same | Runtime.halt from the watchdog’s own timer, ten seconds after its System.exit |
The first two columns differ in three of the eight rows: what clears the flag, when the finally runs relative to the crash report, and whether there is a crash report at all. The third differs from the first in all eight, and the rest of this page is why.
The cast
| class | what it decides | thread |
|---|---|---|
MinecraftServer | the two shutdown flags, the tick loop, and the finally that is the whole of shutdown | Server |
StopCommand | that /stop is one call to MinecraftServer.halt with wait false, at Commands.LEVEL_OWNERS | Server |
DedicatedServer | what wraps the base teardown: the JSON-RPC notification, Util.shutdownExecutors, and the side threads in DedicatedServer.onServerExit | Server |
ServerWatchdog | that a tick past max-tick-time is a dead server, and that the JVM goes with it | Server Watchdog, a daemon |
PlayerList | that every player is written before anyone is disconnected | Server |
ServerChunkCache · ChunkMap | when the world is quiet enough to stop draining, and what a flush save means | Server, with the writes on the IO pool |
LevelStorageSource.LevelStorageAccess | level.dat and the DirectoryLock on session.lock | Server |
Util | the process-wide pools, and the three-second grace each of the two it shuts down gets | any |
/stop, in full
sequenceDiagram
participant MS as MinecraftServer
participant PL as PlayerList
participant SL as ServerLevel
participant SCC as ServerChunkCache
participant LSA as LevelStorageSource.<br/>LevelStorageAccess
participant Disk
Note over MS: StopCommand calls halt with wait false, so running goes false
Note over MS: the lap in progress finishes, then the loop condition fails
MS->>MS: stopped goes true, then stopServer, out of runServer's finally
MS->>MS: PacketProcessor.close, then the connection listener is stopped
MS->>PL: saveAll, then removeAll
PL->>Disk: each player's dat file, stats and advancements
MS->>SL: noSave cleared on every level
loop while any ChunkMap.hasWork
MS->>MS: the tick deadline pushed one millisecond out
MS->>SCC: deactivateTicketsOnClosing, then tick, on every level
MS->>MS: waitUntilNextTick drains the queue for what is left of it
end
rect rgba(0, 0, 0, 0.04)
Note over MS,Disk: one call: MinecraftServer.saveAllChunks with flush
MS->>SL: save, per level, reaching ChunkMap.saveAllChunks
SL->>Disk: region files, entities, poi, and the chunk_tickets saved data
MS->>LSA: saveDataTag, level.dat built into a temp file
LSA->>Disk: the temp replaces level.dat, the old rotated to level.dat_old
MS->>MS: SavedDataStorage.<br/>saveAndJoin, after level.dat and not before
end
MS->>SL: close, ServerChunkCache.close then the entity manager
MS->>LSA: close, releasing the DirectoryLock on session.lock
MS->>MS: Util.shutdownExecutors, then onServerExit stops RCON and query
Note over MS: the Server thread returns, and no non-daemon thread is left
The whole of shutdown, which is one finally block. The shaded band is a
single call, and the three saves inside it are in that order because that call
puts them there — not because MinecraftServer.stopServer asks for them one at
a time.
The command is a flag
StopCommand registers one literal at Commands.LEVEL_OWNERS, sends
commands.stop.stopping and calls MinecraftServer.halt with wait false.
That call assigns MinecraftServer.running — volatile, and the loop’s only
condition — and returns. Nothing else
happens on that line of the console: the rest of the lap runs out — a console
command runs in the connection phase, after every level has ticked, and a
player’s between ticks — and the loop condition at the top of
MinecraftServer.runServer fails on the next pass.
Five other places on this side of the jar call the same method: the server
GUI’s window-close listener and Main’s shutdown hook, both with wait true,
so that they block until the Server thread has finished; the JSON-RPC
management API’s MinecraftServerStateServiceImpl, which takes the flag from
its caller; ServerCommonPacketListenerImpl.onDisconnect, which stops a
singleplayer server when its host logs out; and GameTestServer, which halts
itself when its test run is over. The client adds three more of its own.
Teardown itself is the loop’s finally. MinecraftServer.runServer sets
MinecraftServer.stopped, the plain field other threads read through
MinecraftServer.isStopped, and calls MinecraftServer.stopServer, then calls
MinecraftServer.onServerExit from a nested finally, so that a teardown
which throws still stops the side threads. DedicatedServer.stopServer
wraps the base with NotificationManager.serverShuttingDown before and
Util.shutdownExecutors after.
The front door closes, the guests do not leave
PacketProcessor.close is first, before anything is even logged. Afterwards
PacketProcessor.scheduleIfPossible refuses a packet a Netty thread has just
decoded, and PacketProcessor.processQueuedPackets returns without draining
— so packets already in the queue go the same way as the ones still
arriving. Then ServerConnectionListener.stop closes the channels it
bound, and only those: closing a Netty parent channel does not close the
connections accepted through it. Live sessions are severed after the players are saved, by
PlayerList.removeAll, with the multiplayer.disconnect.server_shutdown
reason. A connection still in handshake, login or configuration has no
ServerPlayer and is in neither list, so it is closed by neither, and simply
dies with the process (protocol
phases: a session exists only
once configuration is acknowledged).
MinecraftServer.stopped also changes how work is accepted, and the two
doors answer differently. MinecraftServer.executeIfPossible throws a
RejectedExecutionException outright. MinecraftServer.scheduleExecutables
merely reports false, so a caller reaching BlockableEventLoop.execute from
another thread runs its task inline on its own thread rather than queueing it
for a loop that has stopped looping (the server
tick is the same two doors
while the server runs).
Players before chunks, and never MinecraftServer.saveEverything
Shutdown does not use the save entry point everything else uses.
MinecraftServer.saveEverything — autosave, /save-all, the integrated
server’s pause, the JSON-RPC save call — is players then chunks in one
call. MinecraftServer.stopServer does the two halves by hand:
PlayerList.saveAll (each player’s data through PlayerDataStorage, plus
their ServerStatsCounter and PlayerAdvancements), then
PlayerList.removeAll, and only much later MinecraftServer.saveAllChunks.
PlayerList.removeAll is thinner than its name: it disconnects each
connection and nothing else, so the tickets those players hold are not what
goes with them. What lets the next step finish is
ServerChunkCache.deactivateTicketsOnClosing, called on every level inside
the drain loop itself.
ServerLevel.noSave is then cleared on every level. /save-off does not
survive /stop.
The drain
ChunkMap.hasWork is the question, and it is a broad one — nine things in
one or: pending light, pending unloads, a non-empty updating map, POI work,
chunks queued to drop, a non-empty unload queue, the worldgen and light dispatchers, and DistanceManager.hasTickets, the
reason the loop terminates at all. While any level answers yes, the server pushes
the tick deadline one millisecond out, calls
ServerChunkCache.deactivateTicketsOnClosing and ServerChunkCache.tick on
each level, and runs MinecraftServer.waitUntilNextTick, which drains the
main-thread queue and polls each level’s chunk executor for what is left of
that millisecond.
One millisecond — each slice of the drain, so unloads and their saves proceed while the main-thread queue keeps taking chunk results.
TicketStorage.deactivateTicketsOnClosing moves every ticket except
TicketType.UNKNOWN into a parked map — the flush save below writes the
persistent ones out as the dimension’s chunk_tickets saved data, under
TicketStorage.TYPE — and that is what ends the loop:
TicketStorage.hasTickets counts only the live map, so parking a ticket stops
it holding a chunk. Parked is not forgotten — which of them survive to the
next boot, and what re-arms them there, belong to tickets and
loading and
starting a
server.
The flush save
MinecraftServer.saveAllChunks with flush true is the real save. The
scoreboard is pushed into its saved data, then each ServerLevel.save:
ServerLevel.saveLevelData joins the level’s own SavedDataStorage, and
ServerChunkCache.save runs the distance manager once more before
ChunkMap.saveAllChunks in flush mode. That last one is a loop, not a
pass — every holder that ChunkHolder.wasAccessibleSinceLastSave, waited on
with BlockableEventLoop.managedBlock until ChunkHolder.isReadyForSaving,
repeated until a whole round saves nothing new — and then
SectionStorage.flushAll for the POI sections, the unloads processed, and
SimpleRegionStorage.synchronize joined so that the IOWorker has actually
put the bytes down (chunk
storage). Entities
follow, through the level’s PersistentEntitySectionManager.
level.dat is written next, the same way at every save, flush or not —
LevelStorageSource.LevelStorageAccess.saveDataTag, a gzipped temp file
through NbtIo.writeCompressed, and a replace that keeps the previous copy (level data and
rules has
the path and the retries). Only after that does the server-wide
SavedDataStorage get its SavedDataStorage.saveAndJoin. There are two tiers
of saved data, and they are flushed at opposite ends of this section.
The closes, and the last thread
ServerLevel.close is ServerChunkCache.close — which saves once more, then
closes the level’s saved data, the ThreadedLevelLightEngine and ChunkMap
— followed by the entity manager. Then the server’s own SavedDataStorage
(whose SavedDataStorage.close is itself a final
SavedDataStorage.saveAndJoin), the MinecraftServer.ReloadableResources,
and last LevelStorageSource.LevelStorageAccess.close, which releases the
DirectoryLock. From that moment the world is openable by anything else.
Util.shutdownExecutors then stops Util.backgroundExecutor and
Util.ioPool with a three-second grace each, inside
DedicatedServer.stopServer and so before DedicatedServer.onServerExit
— nothing may need a worker after that point. Util.nonCriticalIoPool is
untouched, and survives only because its threads are daemons.
DedicatedServer.onServerExit closes the text filter and the GUI and stops
RconThread, QueryThreadGs4 and the ManagementServer. There is no
System.exit anywhere on this path, and none is needed. Every other thread
the server started is a daemon — the console reader, the Netty groups, the
management server’s group, the watchdog — except the RCON and query threads,
which GenericThread.stop joins here in one-second slices, and the IO
pool’s workers, which went a step earlier with Util.shutdownExecutors, and the
chat filter’s pool, whose workers the text filter’s close shuts down, and, with the GUI up, Swing’s event dispatch thread, which ends once MinecraftServerGui.close disposes the frame. So
when MinecraftServer.runServer returns, the Server thread is the last one
left, and the JVM ends because there is nothing to keep it
(the thread reference).
The crash that saves
MinecraftServer.runServer wraps the entire loop, DedicatedServer.initServer
included. Anything thrown out of a tick — a block entity, a mob’s AI — is
logged, turned into a report, saved, and then falls into the same finally.
A packet handler’s exception and a command’s are caught before they get here;
what escapes is an Error, or, from a packet handler, a ReportedException
carrying an OutOfMemoryError.
MinecraftServer.constructOrExtractCrashReport walks the cause chain and
keeps the innermost ReportedException it finds, using that exception’s
own report and noting the outer throwable under a Wrapped in category. A
throwable with no ReportedException anywhere in it becomes a fresh report
titled Exception in server tick loop. Either way
MinecraftServer.fillSystemReport fills it in — the value of
MinecraftServer.running, the player count and roster, the selected and
available data packs, the enabled feature flags, the world-generation
lifecycle, the world seed, and the contents of the server’s
SuppressedExceptionCollector, which has been quietly watching every chunk
load failure, chunk save failure and packet-handler exception since boot —
keeping the latest eight (the message, the class and the place, without the
stack) and a count of every one by place and class.
DedicatedServer.fillServerSystemReport adds two lines, the modded
status and the words Dedicated Server. The file lands in crash-reports/
under MinecraftServer.getServerDirectory, named by
Util.getFilenameFormattedDateTime. MinecraftServer.onServerCrash is a
hook the dedicated server does not override.
A crash on another thread arrives here too. BlockableEventLoop keeps
one static parked report. Util.onThreadException — the uncaught-exception
handler on every Util.backgroundExecutor and IO-pool thread — and
GenerationChunkHolder, when a generation step completes exceptionally,
both call BlockableEventLoop.relayDelayCrash, which parks the report or
suppresses the new one under a report already parked. The next
BlockableEventLoop.pollTask on a loop constructed with crash propagation
throws it as a ReportedException, and the dedicated server is constructed
that way. So a worker that dies does not die silently: it dies as a
tick-loop crash, on the Server thread, at whatever moment that thread next
looks for a task (the server
tick). It does
not save like one. The parked report is never cleared, so the shutdown’s own
drain throws it again the first time it waits for chunks: the players are
written, and so are the chunks the drain’s first pass unloads, and then the
teardown stops before the flush save, leaving the entities, level.dat and
the saved data unwritten, the executors never shut down — the IO pool’s
workers keep the JVM alive until they idle out — and the lock for the
operating system to drop. The integrated server
is constructed with propagation off and hands its report to the client
instead, through IntegratedServer.onServerCrash.
The watchdog that does not
sequenceDiagram
participant SW as ServerWatchdog
participant MS as MinecraftServer
participant JVM
participant Hook as Server Shutdown Thread
participant Disk
Note over MS: wedged inside one tick, past max-tick-time
SW->>MS: getNextTickTime, a deadline now far in the past
SW->>SW: createWatchdog<br/>CrashReport<br/>dumps every thread
SW->>MS: fillSystemReport, read off-thread while the tick still runs
SW->>Disk: the report, to stdout and to crash-reports
SW->>JVM: Runtime.halt armed for ten seconds from now
SW->>JVM: System.exit
JVM->>Hook: run the shutdown hooks
Hook->>MS: halt with wait true, running becomes false
Hook->>MS: then waits for the Server thread, which is the wedged one
MS-->>Hook: nothing, because the tick never returns
Note over SW,Disk: ten seconds later the armed halt fires, with no save run
The only ending that waits on a thread that will never finish: the exit waits for the hook, the hook for the wedged thread, and the armed halt is the only thing that moves. The one message to the Disk lane is the difference between this and a server that dies silently.
ServerWatchdog is a daemon thread started by DedicatedServer.initServer
whenever DedicatedServer.getMaxTickLength is positive — that is
DedicatedServerProperties.maxTickTime, max-tick-time, default sixty
thousand milliseconds, and setting it to zero or less means the thread is
never created and there is no backstop at all. Its loop is short: while
MinecraftServer.isRunning, compare Util.getNanos with
MinecraftServer.getNextTickTime, then sleep exactly until the earliest
moment a violation could be true.
What it compares matters. MinecraftServer.getNextTickTime is the deadline
the loop set for the tick it is running, not a timestamp of when that tick
began, and the tick loop advances it before each tick and again when catching
up after an overload. The watchdog fires when the server is that far past
where it promised to be.
The report comes first, and it is the good part of the design.
ServerWatchdog.createWatchdogCrashReport dumps every thread in the JVM,
sorts them daemon-last, appends the lot as a Thread Dump category, and
grafts the Server thread’s stack trace onto a synthetic error — so the
report’s headline stack is the code that hung. MinecraftServer.fillSystemReport
adds the usual, and the watchdog then adds a Performance stats category
holding the random-tick game rule and ServerLevel.getWatchdogStats for every
level: players, the five commonest entity types and ticking block-entity
types, block and fluid tick counts, chunk source stats. All of it is read from another thread
with no lock on the world, off a world that is mid-tick — which is exactly the trade the
class makes, because the alternative is asking a wedged thread for the
answer. It goes to real stdout through Bootstrap.realStdoutPrintln and to
crash-reports/ like any other report.
Then the wait that cannot end. The watchdog schedules Runtime.halt on a timer and calls
System.exit, which runs the registered shutdown hooks — including the
“Server Shutdown Thread” that Main registered at boot, whose whole body is
MinecraftServer.halt with wait true. That sets MinecraftServer.running
false, which the wedged tick will never read, and then waits for the Server
thread to end. It does not end. System.exit will not return until its hooks
do, so the JVM sits there until the watchdog’s timer fires.
Ten seconds — from the watchdog’s System.exit to its Runtime.halt
(ServerWatchdog.MAX_SHUTDOWN_TIME), and the Server thread writes nothing in
any of them; only writes already queued on Util.ioPool can still land.
The watchdog is a liveness backstop, and reading it as a safe stop gets the
guarantee backwards. Whether anything guards shutdown depends on how
shutdown was reached, because the watchdog loops while
MinecraftServer.running is true and only MinecraftServer.halt ever clears
that flag. After /stop it is cleared before the drain begins, so a server
stuck on “Saving chunks” is stuck with no watchdog left watching it. After a
crash nothing clears it — the crash path never calls MinecraftServer.halt —
so the watchdog
is still counting. The drain loop survives that by resetting the deadline
every pass; the flush save that follows it does not reset anything, so a slow
enough save after a crash can be shot by the watchdog mid-write.
The endings that are /stop under another name
Ctrl-C, SIGTERM and the window button
Ctrl-C at the console and a SIGTERM from a service manager are the same
thing as far as the game is concerned: the JVM runs its shutdown hooks, and
the one Main registered calls MinecraftServer.halt with wait true. The
contrast with the watchdog is in the health of the thread being waited for,
and in the halt nothing here has armed. Here the Server thread is fine, notices the cleared flag at the top of
its next lap, and runs the entire /stop teardown while the hook thread
waits. A Ctrl-C on a healthy server is a /stop, and the JVM does not exit
until the world is on disk. The server GUI’s window-close button does the
same thing from the AWT thread.
Singleplayer ends on a poll
Minecraft.disconnect — reached from Save and
Quit, from a disconnect, and from Minecraft.emergencySave — closes the
client’s connection, then calls IntegratedServer.halt with wait false.
That override first uses BlockableEventLoop.executeBlocking to remove every
player who is not the host, then clears MinecraftServer.running and stops
the LAN pinger. The client then puts up a GenericMessageScreen reading
Gui.SAVING_LEVEL and calls Minecraft.renderFrame in a loop while
MinecraftServer.isShutdown is false. The “Saving world” screen is not a
progress bar and is not driven by the server: it is a render loop spinning on
one question, is the Server thread dead yet
(the client loop). Closing the game window
reaches the same place by a different road: Window.shouldClose makes
Minecraft.runTick call Minecraft.stop, which ends the frame loop, and
Main then calls Minecraft.exitWorldAndClose on its way out. The client’s
“Client Shutdown Thread” is a JVM shutdown hook rather than that path — the
backstop for a kill signal — and after an ordinary exit it finds
Minecraft.singleplayerServer already null.
MinecraftServer.isShutdown is the right question to spin on, and it is the
one fact in this teardown that is not a field: it asks whether the Server
thread is still alive, so nothing can set it and nothing can lie about it.
MinecraftServer.isStopped, which the screen could have watched instead,
goes true at the start of teardown, when nothing has been saved yet — a
“Saving world” screen driven by that one would come down before the world
was written.
The integrated server never calls Util.shutdownExecutors.
IntegratedServer.stopServer tears down published state and defers to the
base. The client owns those pools and shuts them down at the very end of its
own life, long after the world is closed.
What you lose if you kill the process
What an autosave has already written
Ordinary autosave is MinecraftServer.saveEverything with neither flush
nor force, on the countdown the tick keeps at the bottom of every lap
(the server tick) — five
minutes of the server’s scheduled time, whatever the tick rate. It writes every player, every
dirty chunk — under none of the per-chunk spacing that throttles ordinary
saving, because the sweep clears it on the way in (chunk
storage) —
and, unconditionally, level.dat, followed by a scheduled write of the
server’s SavedData. Between the two, the spawn point and the world time (in
level.dat) and the weather, the game rules and the world clocks (each its
own SavedData file — weather, game_rules, world_clocks) on disk are
never more than one autosave stale, even on a server nobody ever stops
cleanly. Everything else — a chest filled a moment ago, a mob that walked
into a new chunk, an inventory change — lives in the LevelChunk, the entity
sections and the player until something saves them; a changed chunk goes out
with the eager sweep once the tick has time and ten seconds have passed since
its last write (chunk
storage).
The honest answer, per ending
After /stop or a crash thrown by the tick itself,
nothing is lost: the drain, the flush save and the joined IOWorker mean the
process does not end until the bytes are down. The crash has one asterisk the
clean stop does not — the watchdog is still armed all the way through it — but
short of a save slow enough to trip it, both endings land the same. After a
watchdog kill, or a
kill -9, or a power cut, you lose everything no autosave, unload, eager sweep or logout had written, plus anything still queued inside the IOWorker — those writes run on
Util.ioPool, and Runtime.halt does not wait for a pool.
What you never lose is access to the world. The lock the boot took is an operating-system one (starting a server), and an operating system drops it when the process dies however it dies — so a world left behind by a killed server opens on the next start.
What you lose with no ending at all
Individual failures are quieter than any of this. A chunk that cannot be
written calls MinecraftServer.reportChunkSaveFailure: logged, added to the
SuppressedExceptionCollector that the next crash report will print, written
out as its own ReportType.CHUNK_IO_ERROR file under debug/, and followed
by a disk-space check. The tick does not stop, the server does not stop, and
the only signs at the time are a line in the log and that file, with a toast
as well in singleplayer.
Where to look
The /stop road first, then the other two. StopCommand ·
MinecraftServer.halt · MinecraftServer.runServer (the loop and its
finally) · MinecraftServer.stopServer · DedicatedServer.stopServer ·
ChunkMap.hasWork · MinecraftServer.saveAllChunks ·
LevelStorageSource.LevelStorageAccess.close · DedicatedServer.onServerExit ·
MinecraftServer.constructOrExtractCrashReport ·
BlockableEventLoop.relayDelayCrash · ServerWatchdog.run ·
Main (the shutdown hook) · IntegratedServer.halt · Minecraft.disconnect
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
IV · The world
Verified against Minecraft 26.3 · Part IV · The machinery that turns a place you have walked to into a place that exists: chunks asked for, made, lit, saved and forgotten, and the five pages off that line — what the place and the hour decide, and the four systems that make the world the line delivers feel alive.
Part III was one thread going round. This part is what it goes round on. A world is too big to hold, so the server holds a moving window of it, and almost everything in this part exists to decide the window’s edge: which chunks are worth building, how far past the edge to build them, which of them tick, which of them you are owed a copy of, and when one is finally written down and let go. A player recognises the part by its edge — the chunks that appear at the rim of render distance as you walk, the mobs that stop moving when you fly away from them, the Saving world bar. Almost none of that edge is one number: render distance, simulation distance and the mob-spawning radius are three different radii, answered by three different mechanisms, and only two of them are settings (two graphs, one store).
The shape of the part
Part IV is a conveyor with a vocabulary page in front of it — and the conveyor
is a ring, because a chunk written out comes back in through the door it
left by. One page defines the thing being handed; four more are the ring, and
a chunk goes round it in order. The other five are not on the line at all
— one is what the place and the hour decide, and four act on the chunk the ring
delivers. Every box below is one of those ten lectures except the one in lower
case, which is a chunk’s state rather than a page: the live LevelChunk the
ring exists to produce.
flowchart TD
CA["2 · Chunk anatomy: sections, palettes, heightmaps"]
TL["3 · Tickets and loading: which chunk, at what level"]
GP["4 · The generation pipeline: EMPTY to FULL"]
LI["5 · Lighting: two 4-bit fields, off the tick"]
CS["6 · Chunk storage: copy, encode, sectors"]
LC["a live LevelChunk in a ticking world"]
EA["1 · Environment attributes: the place and the hour"]
ST["7 · Scheduled ticks: the appointment book"]
FL["8 · Fluids: the book's biggest customer"]
GV["9 · Game events and vibrations: what just happened"]
PI["10 · Points of interest: what is worth going to"]
CA -- "the vocabulary the ring spends" --> TL
TL -- "a holder, a ceiling, three futures" --> GP
GP -- "two of its ten statuses" --> LI
LI -- "sections dirtied, one packet of them" --> LC
LC -- "nobody needs it any more" --> CS
CS -- "a ticket wants it back" --> TL
LC -- "how anything happens later" --> ST
ST -- "one customer, its own page" --> FL
LC -- "what just happened, and who hears it" --> GV
LC -- "what is worth walking to" --> PI
EA -- "a value for a position and an instant" --> LC
The part numbered to the watch order: four pages and the live chunk make the ring, and five arrows run round it, the one into lighting an inclusion rather than a hand-off — two of the pipeline’s own ten statuses, run on a different executor. Chunk anatomy hands the ring its vocabulary, lecture one hands the live chunk its values, and the last four act on the live chunk, one of them through another page.
Follow the numbers rather than the arrows and the surprise is where they disagree: the first lecture is watched first and sits off the line, and the sixth hands back to the third.
Later parts hang off that line rather than feeding it: Part V’s blocks assume the sections and palettes of chunk anatomy, Part VI’s entities tick where the tickets say, and Part XII’s terrain is the cargo on the generation pipeline.
Before you start
The server tick and the level tick. Almost everything here happens on the Server thread inside that loop or on the worker pool beside it — the IO lane that chunk storage is about has threads of its own — and the level tick is where the chunk source is asked to do its five things. Part II’s codecs and registries are assumed wherever a chunk is written to disk or a type is looked up by name, and tags wherever a behaviour is defined by a set the data pack owns — which is most of what the last two pages do.
Nothing in this part needs Part V or beyond.
Watch in this order
Lectures two to six are the chain — nothing later in it can be watched first. The first is off the chain on purpose. Of the last four, fluids assumes scheduled ticks and must follow it; the other three can be watched in any order once you have the vocabulary page.
- Environment attributes and timelines — off the conveyor, ahead of it, and the page Part III already asked you to watch. Whether lava flows fast, what colour the sky is and when a villager goes to work are one mechanism. The night does not set the sky’s colour — it multiplies whatever the biome produced.
- Chunk anatomy — what a chunk is made of, down to the bit storage. The vocabulary the rest of the part spends. A section holding two distinct block states costs exactly what one holding sixteen costs.
- Tickets and loading — a player takes one step east and, at view distance 10, a column twenty-one chunks wide is asked for. Nothing asks for a chunk because it is loaded: it asks for a level, and two graphs reading one ticket store answer different questions about it.
- The chunk generation pipeline — one chunk from EMPTY to FULL through ten statuses and a pyramid of neighbour requirements. Asking for one chunk asks for 529.
- Lighting — a torch is placed. Two 4-bit fields flooded on a worker and published as a copy. There is no light thread, and no light phase of the tick.
- Chunk storage — a chunk nobody needs is copied, encoded and written, on three different lanes, and the unload waits for neither the encode nor the write. Almost every write of your world is one nobody asked for.
- Scheduled ticks — how anything happens later: an appointment book of two queues per chunk, and a dedup rule that quietly drops the second appointment even when it is sooner.
- Fluids — a bucket of water on flat stone. Water turns toward a hole four steps past the block it is about to fill, because every side runs its own search — and a side the water is not allowed to replace still votes on where the rest of it goes.
- Game events and vibrations — a footstep reaches a sculk sensor, through a cascade of tests that is most of the lecture. The sensor never acts on a footstep in the game tick that stamped it, which costs a mob a tick and a player nothing.
- Points of interest — a villager claims a bed from 48 blocks away, the moment a path to it exists. Sleeping in it sets the bed’s occupied flag and tells the index nothing, and the only behaviour that turns that flag into a change in the index can only take a claim away.
Where the part stops
4% of the part’s lines are named on no page in the book, and what it leaves over is
mostly two small libraries of shapes the pages above teach:
world/level/blockscan, a block search that asks each section’s palette
first, as chunk
anatomy describes, and
the modifier classes an attribute’s arithmetic uses, the library environment
attributes
describes rather than enumerates.
Two omissions are deliberate. The world border has no lecture — a
per-dimension SavedData answering a question about a position, with no chunk
to follow and one step of the level tick to move its edge — so it sits on
level data and
rules.
And sending a chunk stops at eligibility: the packet and its pacing are
Part IX’s.
Reference this part uses
Level data and rules — who owns
the seed, the spawn, the rules, the border and the dimensions, and which
file remembers each; it is also where the border itself is explained, for
the reason just given. Game rules — five of
which this part reads. Math and
primitives —
ChunkPos and SectionPos, and the packings the conveyor pages assume.
Block update flags — the flag word
three pages here spend. Registries — most of
this part’s vocabularies are registries: chunk statuses, ticket types, fluids,
game events, point-of-interest types, environment attributes, timelines and
world clocks.
Threads — the
worker pool and the IO lane. Diagram lanes.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Environment attributes and timelines
Verified against Minecraft 26.3 · Part IV · dusk falls over a taiga, and one value is resolved through a stack of layers — on the server for a mob, and again on the client for the sky.
Dusk on the overworld clock is a stretch and not an instant. Between tick
11867 and tick 13670 the sky over a taiga slides from its pale blue towards
black, and the sky over a pale garden slides from its grey towards black by
the same proportion; part-way through, at tick 12542, every mob that burns in
daylight stops being in danger of burning, and is not in danger again until
tick 23460. A player would never
connect the three, and in 26.3 they are one mechanism. An environment
attribute is a named, typed, registered property of the world —
EnvironmentAttributes puts 51 of them in
BuiltInRegistries.ENVIRONMENT_ATTRIBUTE — and the world answers one for a
position and an instant by running a short stack of layers over the
attribute’s default value: the dimension, the biome, the timelines, the
weather. The surprise is in what a timeline holds. The day timeline does not
know what colour a taiga sky is and never learns: its sky track’s keyframes
are not values but modifier arguments, and the night segment is a multiply.
Night does not set the sky’s colour — it multiplies whatever the
biome produced, which is how one data-driven curve darkens every overworld
biome correctly without being told about any of them.
The cast
Eight classes carry a value from the data pack to the sky. The first two thirds of this page is the machinery, object by object; the last third runs dusk through it twice, once on each side.
| class | what it decides | thread |
|---|---|---|
EnvironmentAttribute | the key: a type, a default, an AttributeRange, and four flags — positional, spatially interpolated, syncable, and the full-resolution biomes that EnvironmentAttributes.NATURAL_MOB_SPAWNS sets. It holds no value and no state | — (registry constant) |
EnvironmentAttributeMap | what one dimension or one biome contributes — a modifier and an argument per attribute, never a bare value | — (loaded from data) |
EnvironmentAttributeSystem | the baked per-level resolver: one EnvironmentAttributeSystem.ValueSampler for each attribute some layer mentions | built in the level constructor, read on that level’s thread; a generating region builds its own, on the worker |
Timeline | a clock, an optional period, one AttributeTrack per attribute, and the named instants on that clock | — (loaded from data) |
AttributeTrackSampler | one track baked against a clock, with a one-tick cache of the sampled argument | Server or Render |
ServerClockManager | the owner of day time — one ServerClockManager.ServerClockInstance per registered WorldClock, saved as world_clocks | Server |
ClientClockManager | the client’s copy: free-runs each clock forward between packets | Render |
EnvironmentAttributeProbe | the client’s smoothing layer, living on Camera: 216 biome samples a tick, a lerp every frame | Render |
The stack a value falls through
flowchart TD
DEF["EnvironmentAttribute.defaultValue enters here"]
DIM["1 — dimension: DimensionType.attributes, a Constant folded into the baked base"]
BIO["2 — biome: one Positional layer per attribute some biome mentions"]
TLS["3 — timelines: DimensionType.timelines, one EnvironmentAttributeLayer.TimeBased per track"]
WEA["4 — weather: WeatherAttributes.addBuiltinLayers, only where Level.canHaveWeather"]
ASK{"who built this stack"}
FLASH["two client-only layers, one attribute each"]
SAN["EnvironmentAttribute.sanitizeValue clamps to the AttributeRange"]
DEF --> DIM
DIM --> BIO
BIO --> TLS
TLS --> WEA
WEA --> ASK
ASK -- "the server" --> SAN
ASK -- "the client" --> FLASH
FLASH --> SAN
The four rungs a level’s value falls through, and the one branch that is not on every value’s path: weather is the last word on the server, and the client stacks two more layers above it, each bolted to one attribute — one lerps the sky colour toward the lightning flash, the other pins the sky light factor to 1. A client asking for anything else falls the same four rungs a server does, and a region being generated builds only the first two.
EnvironmentAttributeSystem.Builder.addDefaultLayers stacks those four in
that order and only that order. There is no priority number anywhere and no
ordering data: a biome cannot run before its dimension, and weather is the
last word on the server. The client’s two extra layers are not a fifth rung:
ClientLevel adds each with
EnvironmentAttributeSystem.Builder.addTimeBasedLayer against one named
attribute — EnvironmentAttributes.SKY_COLOR and
EnvironmentAttributes.SKY_LIGHT_FACTOR — so they are two entries in two
attributes’ stacks and are absent from the other forty-nine. Each is a no-op
unless ClientLevel.getSkyFlashTime is above zero. The stack is per
attribute, too — an attribute nothing
in the level mentions has no EnvironmentAttributeSystem.ValueSampler at
all, and EnvironmentAttributeSystem.getValue hands back its default. And it
is baked once: the level’s whole stack is built in the ServerLevel and
ClientLevel constructors and never rebuilt, the only writer being
ServerLevel.setEnvironmentAttributes, which is deprecated, marked for
testing and called only from TestEnvironmentDefinition. A data-pack reload
does not touch it.
Each rung earns its shape. The dimension’s is an
EnvironmentAttributeLayer.Constant, and
EnvironmentAttributeSystem.bakeLayerSampler walks off the front of the list
folding every leading constant into one baked base value, so a dimension
costs nothing at read time. The biome’s is an
EnvironmentAttributeLayer.Positional, added once per attribute that any
biome in the whole registry mentions — in vanilla, thirteen attributes across
sixty-seven biome files, with visual/sky_color in fifty-seven of them.
Timeline
layers are EnvironmentAttributeLayer.TimeBased, one per track, and so is
weather: one for each of the nine attributes named by
WeatherAttributes.RAIN or WeatherAttributes.THUNDER, blending rain in
first and thunder second. Rain is weighted at Level.getRainLevel minus the
thunder level, so a thunderstorm never counts twice. Level.canHaveWeather
wants sky
light, no ceiling and not the End, so a rain-free dimension does not carry a
weather layer that does nothing: it carries none.
ClientLevel adds two more after those four, both keyed on the lightning
flash that LightningBolt sets through Level.setSkyFlashTime. One lerps
EnvironmentAttributes.SKY_COLOR a fixed 22% toward a pale blue-white, the
other pins EnvironmentAttributes.SKY_LIGHT_FACTOR to 1 outright, and both
read the flash through the accessibility option Hide Sky Flashes,
which reports a flash time of zero while it is switched on — so the two
layers are still there and simply never fire. (The End’s sky flash is a
different thing
entirely — EndFlashState, read by the renderers rather than through the
stack; see lightmap, fog and sky.)
An attribute is positional unless its builder says otherwise — positional
meaning its answer is allowed to differ from block to block — and that flag
is what polices who may write it. Biome.getAttributes is read through
EnvironmentAttributeMap.CODEC_ONLY_POSITIONAL, so naming a non-positional
attribute in a biome file is a load error, and no biome can locally change
sky light level or lava speed. WorldGenRegion.environmentAttributes shuts
the door from the other side: a region being generated builds a stack of the
two static rungs alone, dimension and biome, so a feature or a spawn that asks
about the environment during generation gets the place and never the hour,
because generation must not depend on the hour.
Arguments, not values
EnvironmentAttributeMap is not a map of values. It is a map of
EnvironmentAttributeMap.Entry, and an entry is an argument plus an
AttributeModifier. EnvironmentAttributeMap.Builder.set is sugar for
EnvironmentAttributeMap.Builder.modify with AttributeModifier.override;
the interesting entries multiply, blend, maximise or or into whatever the
layer below produced. AttributeTrack is the same shape — a modifier plus a
KeyframeTrack of arguments — which is why Timeline.Builder.addTrack is
only the override case and Timeline.Builder.addModifierTrack is the general
one, and why the day timeline can say multiply sky light by 0.267 at night
rather than sky light is 4 at night. In a data pack the shorthand shows: an
entry written as the value itself means override, even for an attribute whose
value is an object, and any other entry is an object carrying a modifier and
an argument.
What a type allows
AttributeType is a record of a
value codec, an AttributeType.modifierLibrary of the operations legal on
that type and a codec for them, four separate LerpFunctions, one for
each way two values of it can meet, and two converters.
| lerp slot | used when |
|---|---|
AttributeType.keyframeLerp | between two keyframes of a timeline track that overrides the value — a track of modifier arguments uses the modifier’s own AttributeModifier.argumentKeyframeLerp instead |
AttributeType.stateChangeLerp | fading weather in and out |
AttributeType.spatialLerp | across a biome boundary |
AttributeType.partialTickLerp | between two client ticks, inside a frame |
AttributeTypes registers fifteen of them: two boolean kinds, three numeric,
two colour, six a data pack sets whole — a moon phase, a villager activity, a
bed rule, a particle, a piece of background music, the ambient sounds — and
two that combine, the ambient particles, a list that cross-fades, and the
mob-spawn settings, which overlay. One built by
AttributeType.ofNotInterpolated
gets a step function in all four slots, each with its own threshold, which is
how a MoonPhase snaps while a colour slides; AttributeType.toFloat and
AttributeType.toInt are nullable, and their presence decides whether a loot
table can read an attribute as a number, which the trace below reaches. Each
type publishes a
small, closed library of operations —
six logic gates for a boolean, six arithmetic ones for a float, five for a
colour, four of them ways to combine two colours — and a track or an entry
that asks for one the type does not publish fails where it is built,
AttributeType.checkAllowedModifier in the game’s own builders and the type’s
modifier codec for a data pack, so an illegal combination is a load error
rather than a runtime surprise.
Three codecs then decide who may write what:
| codec | used by | effect |
|---|---|---|
EnvironmentAttributeMap.CODEC | DimensionType.attributes | anything |
EnvironmentAttributeMap.CODEC_ONLY_POSITIONAL | Biome.getAttributes | rejects non-positional attributes |
EnvironmentAttributeMap.NETWORK_CODEC | DimensionType.NETWORK_CODEC, Biome.NETWORK_CODEC | drops every non-syncable entry before the wire |
Who owns the clock
WorldClock is a unit record. It holds nothing at all: it is an identity
token in the Registries.WORLD_CLOCK registry, and vanilla registers two,
WorldClocks.OVERWORLD and WorldClocks.THE_END. Every piece of state lives
in ServerClockManager.ServerClockInstance — a total tick count, a fractional
partial tick, a rate and a paused flag — and the manager holding those is a
SavedData under ServerClockManager.TYPE, saved once for the whole server
as world_clocks.
The four numbers, and the two records that copy them
The live instance is mutable, so its numbers exist twice more as records.
ClockState is the saved form and carries all four; PackedClockStates is
the map of them a save file holds. ClockNetworkState is the wire form and
carries three — total ticks, partial tick and rate — and the one it leaves
behind, the paused flag, is the whole of what the client does not get.
ClockManager is the one thing the two managers share, an interface with a
single method, ClockManager.getInstance: this clock’s ClockInstance —
its total tick count, partial tick, rate and pause. Everything a
reader of an attribute needs from a clock is behind that method, which is why
AttributeTrackSampler can be the same class on both sides, and why
ServerClockManager is the owner of day time in this book.
What moves a clock
MinecraftServer calls ServerClockManager.tick once per server tick,
inside the clocks profiler zone and only while the tick-rate manager runs
normally; the GameRules.ADVANCE_TIME check sits inside the method itself,
and gates every clock at once, whereas ServerClockManager.setPaused gates one.
Each unpaused instance then gains its rate, accumulating the fraction, so a
clock at rate 0.5 gains a tick every other server tick and one at rate 1000
gains a thousand — /time rate accepts anything from 0.00001 to 1000.
Naming an instant on a clock
A ClockTimeMarker is a named instant on a clock: ClockTimeMarkers.DAY,
NOON, NIGHT, MIDNIGHT, WAKE_UP_FROM_SLEEP, ROLL_VILLAGE_SIEGE.
Markers are declared inside timelines and collected onto the clock by
ServerClockManager.init, and Timeline.validateRegistry fails the whole
registry load if two timelines on one clock declare the same one. The subset
/time suggests is the one flagged ClockTimeMarker.showInCommands, though
it accepts any marker on the clock;
ServerClockManager.isAtTimeMarker is how VillageSiege asks whether the
siege roll is due, and ServerLevel.tick calls
ServerClockManager.moveToTimeMarker to jump the clock when enough players
are asleep. Everything TimeCommand offers is offered twice, once against
the source level’s DimensionType.defaultClock and once under /time of
against a clock the player names, because a command that says set the time
has to be told whose time it means.
The four timelines
| timeline | period | what it carries |
|---|---|---|
Timelines.OVERWORLD_DAY | 24000 | the whole day/night curve — sun, moon and star angles, sky and fog colours, sky light, and the gameplay flags that flip at dusk |
Timelines.MOON | 192000 — eight days | the moon phase, and the surface slime spawn chance riding the same steps |
Timelines.VILLAGER_SCHEDULE | 24000 | EnvironmentAttributes.VILLAGER_ACTIVITY and EnvironmentAttributes.BABY_VILLAGER_ACTIVITY |
Timelines.EARLY_GAME | none | one ramp that ands EnvironmentAttributes.CAN_PILLAGER_PATROL_SPAWN with false until tick 120000 |
Three of the four have a period and are cycles. The fourth is the interesting
one: a timeline with no period is not a cycle at all — its track runs once
against the clock’s total ticks and then holds its last value forever, which
is what makes Timelines.EARLY_GAME a switch, thrown when the overworld clock
passes 120000 ticks and closed again only if /time sets the clock back below
it, rather than something that comes round again.
All four run on WorldClocks.OVERWORLD; no timeline is bound to the End’s
clock, which the End’s dimension type names as its default only, the clock
/time sets there and the End’s flash reads. Which of them a dimension runs
is a tag on
DimensionType.timelines: TimelineTags.IN_OVERWORLD names the day, moon
and early-game timelines on top of TimelineTags.UNIVERSAL, while
TimelineTags.IN_NETHER and TimelineTags.IN_END name only the universal
one, which holds the villager schedule. That schedule is the clearest case of
a timeline driving something that is not a colour: Brain.setSchedule takes
an EnvironmentAttribute of Activity, Villager is the only caller —
adults read EnvironmentAttributes.VILLAGER_ACTIVITY and babies
EnvironmentAttributes.BABY_VILLAGER_ACTIVITY, two tracks on the one
timeline — and Brain.updateActivityFromSchedule samples it at the
villager’s own position, but only when more than twenty game ticks have passed
since it last looked. Where the activity it reads then sends a villager is in
points of interest.
What crosses the wire
The rules travel, never the resolved values. Registries.TIMELINE and
Registries.WORLD_CLOCK are in RegistryDataLoader.SYNCHRONIZED_REGISTRIES
— the timeline through Timeline.NETWORK_CODEC, so only syncable tracks go —
while Registries.ENVIRONMENT_ATTRIBUTE and Registries.ATTRIBUTE_TYPE are
built-in code registries that never go out at all.
Syncable is the third flag, and it draws the line between the two sides. Thirty-three of the 51 carry it: every one of the 24 visual/ attributes and all four audio/ ones, and five of the gameplay flags — sky_light_level, fast_lava, water_evaporates, piglins_zombify, creaking_active. The eighteen left behind are gameplay decisions the server makes alone: whether monsters burn, whether a raid can start, what a villager should be doing now. Dropping their entries and tracks before the wire costs the client almost nothing: the one place it asks for them is the bed rules, read through the sleeping code it shares with the server, and there it gets the defaults. So the client’s stack is shorter than the server’s, and identical everywhere else the client looks.
Clock state rides
ClientboundSetTimePacket: a game time — the world’s own tick count, which no clock owns — plus a ClockNetworkState — total
ticks, partial tick, rate — per clock in its map.
ServerClockManager.createFullSyncPacket fills that map on join, respawn and a change of dimension, and on a GameRules.ADVANCE_TIME change. Every mutator broadcasts a one-clock update. The routine broadcast from MinecraftServer.forceGameTimeSynchronization, once every twenty ticks, sends an empty map and nothing but the game time.
ClientClockManager.handleUpdates adopts what arrives and
ClientClockManager.tick free-runs the rest — which is why a paused clock
travels as rate 0: the client has no paused flag to receive.
Dusk, and the same question asked twice
A mob asks whether it should be burning, and the camera asks what colour the sky is. Both go through the stack above, and this is the machinery running.
sequenceDiagram
participant SL as ServerLevel
participant EAS as Environment<br/>AttributeSystem
participant EVS as Environment<br/>AttributeSystem.<br/>ValueSampler
participant Mob as Mob
participant ATS as Attribute<br/>TrackSampler
participant KTS as Keyframe<br/>TrackSampler
Note over SL,KTS: one server tick
SL->>EAS: invalidateTickCache — before the world border, before the weather
EAS->>EVS: the cached value is dropped, the cache tick id bumped
Mob->>EAS: getValue(MONSTERS_BURN, position)
EAS->>EVS: getValue — is any layer of this attribute positional?
EVS->>EVS: none is — start from the baked base, the default false
EVS->>ATS: applyTimeBased(value, cache tick id)
ATS->>KTS: sample at the overworld clock's ClockInstance.totalTicks
KTS-->>ATS: the argument — false at 12542, true again at 23460
ATS-->>EVS: the modifier is BooleanModifier.OR, applied to the value
EVS->>EVS: the weather layer is on nine attributes, not this one
EVS-->>Mob: false, clamped and cached for the tick
One attribute resolved once, on the server, inside one tick: the level’s only contribution is the first two arrows, which throw last tick’s answer away, and every arrow after them is the stack being walked because a mob asked. Nothing here is pushed — a value exists because something wanted it.
Read the arrows as decisions. EnvironmentAttributeSystem.invalidateTickCache
computes nothing: it drops each sampler’s cached value and bumps a counter,
and that counter is the identity every track sampler compares against.
AttributeTrackSampler.applyTimeBased keeps a one-entry cache of the sampled
argument and reuses it for every reader arriving with the same tick id, so
a thousand mobs asking EnvironmentAttributes.MONSTERS_BURN cost one
keyframe sample between them.
The step that reads oddly is the one where the sampler asks whether any layer
of the attribute is positional and finds that none is.
EnvironmentAttributes.MONSTERS_BURN is a positional attribute, and yet
its stack is one layer deep, because in vanilla nothing but the day timeline
mentions it: no dimension type, no biome.
EnvironmentAttributeSystem.ValueSampler decides by layers, not by the
flag, so with no positional layer present the position is ignored and the
whole answer is memoised for the tick. The flag still governs where the
attribute may be read: it makes EnvironmentAttributeCheck and
the two EnvironmentAttributeValue number providers declare LootContextParams.ORIGIN among the
parameters they read, and the default answers when a context has none, and it
makes EnvironmentAttributeSystem.getDimensionValue throw
in a development build if asked for a positional attribute at all. Only two
attributes are built EnvironmentAttribute.Builder.notPositional — sky light
level and lava speed — and three call sites read them that positionless way:
Level.updateSkyBrightness for EnvironmentAttributes.SKY_LIGHT_LEVEL, and
LavaFluid.isFastLava and Entity for EnvironmentAttributes.FAST_LAVA,
the two sites that between them decide how fast lava flows and
how hard it shoves. A fourth site names no attribute at all:
EnvironmentAttributeReader sends any non-positional attribute down this road
when a loot context asks for one.
The period matters when a KeyframeTrackSampler is built: for a periodic
track it bakes two extra segments, last keyframe to first on either side of
the loop, so a value interpolates across the wrap — tick 0, where the day
timeline’s period closes and ClockTimeMarkers.WAKE_UP_FROM_SLEEP sits —
instead of snapping, and KeyframeTrackSampler.sample reduces the clock’s
total ticks with a floor-mod before choosing one. EasingType supplies the
curve, and
the day timeline’s sun, moon and star angles share one symmetric cubic
Bézier whose two keyframes both sit at tick 6000, so the baked segment runs
noon to noon. The sun therefore turns slowest at its zenith — two thirds of
the linear rate — and fastest at midnight, at about six fifths of it. That is
why a Minecraft day is not two equal halves: the sun spends roughly 13,560
ticks above the horizon against 10,440 below.
The same value on the client
The client resolves the same stack and then does two things to it the server never does. Both of them are in the picture: one is about space, the other about time.
sequenceDiagram
participant Camera as Camera
participant EAP as Environment<br/>AttributeProbe
participant GS as GaussianSampler
participant SAI as SpatialAttribute<br/>Interpolator
participant EAS as Environment<br/>AttributeSystem
participant SR as SkyRenderer
Note over Camera,SR: one client tick
Camera->>EAP: tick(level, position) — once per client tick
EAP->>GS: sample around the camera
GS->>SAI: accumulate(weight, that biome's attributes) — 216 times
Note over Camera,SR: between ticks, once per frame
SR->>EAP: getValue(SKY_COLOR, partialTicks)
opt only on the first frame of the tick that asks for this attribute
EAP->>EAS: getValue(attribute, position, interpolator)
EAS->>SAI: applyAttributeLayer — weighted blend of every biome in range
end
EAP-->>SR: AttributeType.partialTickLerp between last tick's value and this one
The same stack, on the other side of the wire, with the client’s two kinds of smoothing around it. Read the two note bars as the boundary they mark: the 216 biome samples happen once a tick whatever the frame rate, and the opt box is the part of the frame nobody pays for twice — the stack is resolved by the first frame that asks and every later frame in the same tick only lerps.
The client resolves the same stack from the same data — it is never sent
a resolved value. What it adds is two kinds of smoothing the server never
does. In space, EnvironmentAttributeProbe.tick prunes, clears, then runs
GaussianSampler.sample over a 6×6×6 neighbourhood of quart-resolution biome
cells — the 4×4×4 blocks a biome is stored per — for 216 samples in all. The
weights come from a seven-entry kernel, 0-1-4-6-4-1-0, with each of the six
taps along an axis lerped between two neighbouring entries by the camera’s
sub-cell offset: the weights slide as the camera moves instead of snapping
when it crosses a cell. Those weights accumulate into a
SpatialAttributeInterpolator, whose
SpatialAttributeInterpolator.applyAttributeLayer applies each contributing
biome’s modifier to the base value and lerps the results together by
weight. Only the 21 attributes flagged
EnvironmentAttribute.isSpatiallyInterpolated are resolved that way; anything
else takes the single biome under the position. But that flag is not a gate on
the sampling: the probe runs its 216 samples every tick regardless, and the
test is applied afterwards, when the layer is applied. The server pays none of
it — it passes no interpolator at all. In time, each probed value keeps last tick’s
answer beside this tick’s and returns AttributeType.partialTickLerp between
them — and prunes itself, dropping any value nobody read during a tick. The
resolve itself is once a tick, not once a frame:
EnvironmentAttributeProbe.ValueProbe.tick nulls this tick’s answer, the first
frame that asks for the attribute fills it in by walking the whole stack, and
every frame after that in the same tick does nothing but the lerp. Only the
first frame in a tick that asks pays for the walk.
The probe lives on Camera, ticked from Camera.tick and emptied by
Camera.reset, and everything that draws the sky goes through it — the sky,
the lightmap, the two fog environments, the clouds and the music. That is why
lightmap, fog and sky never touches
EnvironmentAttributeSystem directly. The probe is not a wall, though: the
clock item reads sun_angle and moon_phase off
ClientLevel.environmentAttributes directly, and so does ClientLevel
itself for ambient particles.
The last difference is a tick wide, and it is the reason the two sides can
disagree about the sky for one tick at dusk. They invalidate at opposite ends
of the tick: ServerLevel.tick calls
EnvironmentAttributeSystem.invalidateTickCache before the world border and
the weather, then runs Level.updateSkyBrightness later in the same method,
once sleeping and weather have resolved; ClientLevel.tick does the reverse,
Level.updateSkyBrightness first and invalidation last, so the client’s
sky-darken value comes from the previous tick’s clock.
Questions players ask
Why does the nether have no night? It has no day timeline: the nether’s
and the End’s DimensionType.timelines both resolve to
TimelineTags.UNIVERSAL alone. Its environment is constants instead — the
dimension type pins gameplay/sky_light_level, gameplay/fast_lava,
gameplay/water_evaporates and twelve more, which
EnvironmentAttributeSystem.bakeLayerSampler folds into a base value that
never changes again.
Does setting the time in the overworld move the End? No: each clock keeps
its own ServerClockManager.ServerClockInstance. Every mutator does invalidate the
cache on every level at once, though — ServerClockManager walks
MinecraftServer.getAllLevels on each change, because a time jump must not
leave half a tick of stale sky behind.
Why do pillager patrols not show up on day one? Because
Timelines.EARLY_GAME is the timeline with no period, and its single
modifier track ands EnvironmentAttributes.CAN_PILLAGER_PATROL_SPAWN with
false until tick 120000 — a hundred minutes of play at the normal rate, sooner
for every night slept through — and with true after.
Nothing counts your days; the switch is one keyframe on a track that never
comes round again.
For a 1.21-era reader. Three sets of names moved. The gameplay booleans on
DimensionType— ultrawarm, bed_works, piglin_safe, respawn_anchor_works — are entries inDimensionType.attributesnow, and ultrawarm has become several,EnvironmentAttributes.FAST_LAVAandEnvironmentAttributes.WATER_EVAPORATESchief among them.BiomeSpecialEffectskeeps only the water, foliage and grass tints: sky and fog areBiome.getAttributes. And the villager Schedule class isTimelines.VILLAGER_SCHEDULE, a data-packTimelinelike any other.
Where to look
EnvironmentAttributes · EnvironmentAttribute.Builder · AttributeTypes ·
EnvironmentAttributeMap.Entry · EnvironmentAttributeSystem.Builder ·
EnvironmentAttributeSystem.bakeLayerSampler ·
EnvironmentAttributeSystem.invalidateTickCache ·
WeatherAttributes.addBuiltinLayers · Timelines · Timeline.createTrackSampler ·
AttributeTrackSampler.applyTimeBased · KeyframeTrackSampler.sample ·
ServerClockManager.tick · ClientClockManager.handleUpdates ·
EnvironmentAttributeProbe.tick · SpatialAttributeInterpolator.applyAttributeLayer ·
GaussianSampler.sample · TimeCommand
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Chunk anatomy
Verified against Minecraft 26.3 · Part IV · One block is placed, and the write travels down through the chunk until it lands in four bits of one long.
You place a single block of deepslate at y −40, in a section that until now
held nothing but stone and air. The click ends in LevelChunk.setBlockState,
and the write goes down four objects: a LevelChunkSection sixteen blocks
tall, a PalettedContainer of 4,096 block states, a palette that turns the
state into a small integer, and a SimpleBitStorage that puts that integer
into some fixed number of bits of one long. Everything else in this part
moves chunks — tickets load them, the pyramid generates them, the light
engine walks them, the region file stores them. This is the page that looks
inside, and it is the vocabulary the rest of the part spends. Begin with the
part that surprises people: a section holding two distinct block states
costs exactly what one holding sixteen costs — four bits an entry, 256
longs, on disk as well as in memory — and the block that makes it seventeen
re-encodes all 4,096 entries into a wider storage before it can be written.
The cast
| class | what it decides | thread |
|---|---|---|
ChunkAccess | everything a chunk has whatever its shape: position, height, sections, heightmaps, block entities, structures, the two volatile flags | abstract — whichever thread owns the shape below |
ProtoChunk | a chunk under construction: status, entities as NBT, the light engine it reports to | written on the worker pool, one writer at a time |
LevelChunk | a chunk that is part of a Level: block entities, tickers, tick containers, the full-status supplier | the Server thread — on the client, the Render thread |
ImposterProtoChunk | what a still-generating neighbour sees when the chunk it asked for is already live | built on the Server thread; read on the worker pool — by a neighbour’s step on the worldgen executor or in the buildTerrain fork, and by the light engine on the light executor |
LevelChunkSection | 16×16×16: two palette containers and four counters that let a whole section be skipped | whichever thread holds its permit |
PalettedContainer | the mapping from 4,096 (or 64) entries to values, and when to widen it | one writer at a time — a second is detected, blocks, and both threads die; reads are lock-free |
Strategy | which palette and which bit width each entry count deserves, for block states and for biomes | immutable, shared by every container in the level |
Heightmap | the top of each of 256 columns, for one definition of top | with the chunk that owns it |
The four shapes a chunk takes
classDiagram
class ChunkAccess {
<<abstract>>
position and height
sections, heightmaps, block entities, structures
}
class ProtoChunk {
under construction
written on the worker pool
}
class LevelChunk {
part of a Level
owned by the Server thread
}
class ImposterProtoChunk {
what a still-generating neighbour is handed
sections shared, most other writes dropped
}
class EmptyLevelChunk {
void air, one fixed biome
}
ChunkAccess <|-- ProtoChunk
ChunkAccess <|-- LevelChunk
ProtoChunk <|-- ImposterProtoChunk
LevelChunk <|-- EmptyLevelChunk
ProtoChunk ..> LevelChunk : promoted, by ChunkStatusTasks.full
ImposterProtoChunk --> LevelChunk : holds the one it stands in for
The four shapes and the one abstract base, with the two relations that are not inheritance: the dashed arrow is the promotion the whole part exists to reach, and the solid one is a wrapper holding the chunk it pretends to be. The two subclasses hang off different parents, which is the thing to look at — an imposter is a proto chunk and an empty chunk is a live one.
The diagram is the hierarchy and the two links across it, because the
hierarchy is short enough to be the whole truth. ChunkAccess is the abstract
chunk and has
exactly two direct concrete lines — ProtoChunk (ChunkType.PROTOCHUNK) and LevelChunk
(ChunkType.LEVELCHUNK) — with ImposterProtoChunk a subclass of the first
and EmptyLevelChunk of the second. Nothing else extends it.
Every one of them carries the same core. ChunkAccess.chunkPos says where
it is, and a LevelHeightAccessor says how tall: LevelHeightAccessor.getMinY
and LevelHeightAccessor.getHeight are the only two facts about height there
are, and the overworld’s −64 and 384 give LevelHeightAccessor.getSectionsCount
of 24, section Y −4 through 19. Beside them sit the heightmaps, the block
entities in two maps (ChunkAccess.blockEntities live, ChunkAccess.pendingBlockEntities
still NBT, ChunkAccess.getBlockEntitiesPos the union),
ChunkAccess.structureStarts and ChunkAccess.structureReferences, the
per-section ChunkAccess.postProcessing offsets to revisit
after load (ProtoChunk.packOffsetCoordinates packs four bits each of x, y
and z into a short), ChunkAccess.inhabitedTime behind local difficulty
(the level
tick),
ChunkAccess.upgradeData
and the nullable ChunkAccess.blendingData whose presence is
ChunkAccess.isOldNoiseGeneration (blending),
and two volatile flags —
ChunkAccess.unsaved, whose test-and-clear ChunkAccess.tryMarkSaved the
saver uses, and ChunkAccess.isLightCorrect, saved as isLightOn. It is
also three interfaces at once — LightChunk, which is what the light engine
reads through LightChunk.findBlockLightSources and
LightChunk.getSkyLightSources, plus StructureAccess and
BiomeResolver — and ChunkAccess.getPersistedStatus is
the ChunkStatus that goes to disk, with ChunkAccess.getHighestGeneratedStatus
folding in BelowZeroRetrogen.targetStatus for a chunk still being deepened.
A ProtoChunk adds what only generation needs: a volatile
ProtoChunk.status (ProtoChunk.setPersistedStatus also retires a finished
BelowZeroRetrogen), a ProtoChunk.lightEngine from
ProtoChunk.setLightEngine that it reports to only once the status
ChunkStatus.isOrAfter ChunkStatus.INITIALIZE_LIGHT, its entities as a
list of CompoundTag (ProtoChunk.addEntity serialises on the spot), and
ProtoChunkTicks that
ProtoChunk.unpackBlockTicks turns into LevelChunkTicks on promotion
(appointments that survive a restart).
The pool that fills all of that in
is the generation pipeline. Ask it for a
biome before ChunkStatus.BIOMES and ProtoChunk.getNoiseBiome throws
Asking for biomes before we have biomes.
A LevelChunk adds what only a live chunk needs: LevelChunk.level,
LevelChunk.setLoaded, a supplier of FullChunkStatus the holder owns
(the four statuses), two LevelChunkTicks that
LevelChunk.registerTickContainerInLevel attaches to the level’s queues and
LevelChunk.unregisterTickContainerFromLevel detaches, the ticker map, a
one-shot LevelChunk.postLoad processor that LevelChunk.runPostLoad
fires, the per-section LevelChunk.gameEventListenerRegistrySections — an
EuclideanGameEventListenerRegistry each, built on demand and only on a
server (listeners live in the
chunk) — and a
LevelChunk.unsavedListener that LevelChunk.markUnsaved fires only on
the false-to-true edge, which is how the server’s dirty set learns of a
change without scanning. Its LevelChunk.getPersistedStatus is always
ChunkStatus.FULL.
ImposterProtoChunk exists because a neighbour still generating asks the
holder for “the chunk at status X” and must be handed something
ProtoChunk-typed even when that chunk is already live. Reads delegate to
ImposterProtoChunk.getWrapped; writes are dropped unless allowWrites,
which both of the two places that construct one pass as false — heightmaps, structure starts and
references and block-entity NBT unconditionally, and the rest for want of the
flag. The sections are the exception: ImposterProtoChunk.getSections hands
back the wrapped chunk’s array unconditionally, and the single-section
ImposterProtoChunk.getSection, though gated on the flag, falls back to that
same array, so a write through a section lands on the live chunk; and
ImposterProtoChunk.markUnsaved and ImposterProtoChunk.setLightCorrect
always pass through and ImposterProtoChunk.canBeSerialized is false,
because the LevelChunk under it is what gets saved. Its
ImposterProtoChunk.fixType maps a request for a _WG heightmap onto the
live one, but only inside ImposterProtoChunk.getHeight: asking it to
create a _WG heightmap creates a real one on the live chunk.
What makes that wrapper honest is what promotion does not do. ChunkAccess
always allocates its own array and copies the section references in, so a
promoted chunk leaves two chunks holding two arrays over one set of
LevelChunkSection objects: writing through either is writing the same
blocks, and the proto chunk a generating neighbour is still holding goes on
working. The imposter is a third handle on the same sections.
EmptyLevelChunk is the other direction: Blocks.VOID_AIR everywhere,
EmptyLevelChunk.isEmpty true where LevelChunk.isEmpty is false, one
fixed biome, and EmptyLevelChunk.getFullStatus a flat
FullChunkStatus.FULL. ClientChunkCache.emptyChunk is one shared instance
handed out for any client miss, and only when the caller asked to load or
generate — otherwise the miss returns null; PathNavigationRegion builds
its own for whatever a mob’s pathfinder cannot see. The real client chunks
live in ClientChunkCache.Storage, an AtomicReferenceArray ring whose
ClientChunkCache.Storage.onSectionEmptinessChanged and its double-buffered
added and removed sets are the renderer’s feed of which sections exist.
Sections and their four counters
A chunk’s block storage is four things nested inside each other — the chunk, its sections, each section’s containers and each container’s data — and the four counters hang off the second. Here is the whole of it.
flowchart TD
LC["LevelChunk: one 16 by 16 column of the build height"]
HM["at least four Heightmaps, 256 entries of 9 bits each"]
ARR["ChunkAccess.sections: 24 LevelChunkSection in the overworld, never a null slot"]
CNT["four shorts: non-empty blocks, fluids, and a ticking count of each"]
ST["LevelChunkSection.states: PalettedContainer of BlockState, 4,096 entries"]
BIO["LevelChunkSection.biomes: PalettedContainerRO of Biome, 64 entries"]
D1["its own PalettedContainer.Data: a Palette and a BitStorage, sized together"]
D2["its own PalettedContainer.Data, on a shorter ladder"]
LC --> HM
LC --> ARR
ARR --> CNT
ARR --> ST
ARR --> BIO
ST --> D1
BIO --> D2
A chunk’s block storage, down to the bit storage: the two containers of a
section have a PalettedContainer.Data each and never share one, and the four
counters hang off the section rather than the containers, which is why they
can answer without touching either. Nothing in this picture is light — the two
4-bit
fields live in the light engine’s own storage, not on the section
(lighting).
The array never has a hole: ChunkAccess.replaceMissingSections runs in the
constructor and fills every empty slot with a fresh all-air section from the
level’s PalettedContainerFactory, so nothing that walks sections checks
for null.
The four counters are what make a section cheap to skip.
LevelChunkSection.setBlockState adjusts all four from the outgoing and
incoming state on every single write — LevelChunkSection.nonEmptyBlockCount
(zero is LevelChunkSection.hasOnlyAir, which is also how
LevelChunk.getBlockState answers air without touching a palette),
LevelChunkSection.fluidCount, LevelChunkSection.tickingBlockCount and
LevelChunkSection.tickingFluidCount — and LevelChunkSection.isRandomlyTicking
is the or of the last two. That one boolean lets ServerLevel skip a
section of solid stone without looking at any of its 4,096 blocks. Only a
load from disk recounts: LevelChunkSection.recalcBlockCounts runs from the
two-container constructor, whose only caller is SerializableChunkData.
On a client, two of the four are permanently wrong and nothing notices.
LevelChunkSection.write carries only the non-empty-block and fluid counts,
and LevelChunkSection.read takes exactly those two and never recounts, so a
client section starts with LevelChunkSection.tickingBlockCount and
LevelChunkSection.tickingFluidCount at zero and only ever counts what has
changed since the chunk arrived. The sole reader of
LevelChunkSection.isRandomlyTicking is ServerLevel, and the client runs no
random ticks.
Biomes share the section but are coarse and read-only.
Two bits per axis, 64 entries of 4×4×4 blocks each — the 2
LevelChunkSection.BIOME_CONTAINER_BITS names. The field is a
PalettedContainerRO and the
published one is never mutated: LevelChunkSection.fillBiomesFromNoise,
LevelChunkSection.read and LevelChunkSection.readBiomes each build a
replacement through PalettedContainerRO.recreate, fill it, and swap the
reference. The block-state container is the only one anything writes to in
place.
Two different copies leave a section. The saver takes
LevelChunkSection.copy, a deep copy of both containers and all four
counters, on the Server thread inside SerializableChunkData.copyOf — the
IO lane never sees a live section, and even the NBT encoding of the copy
runs on the background pool (copy on the server, encode on a worker, write on
the IO
lane).
The client
mesher takes something cheaper: a SectionCopy takes PalettedContainer.copy
of the block-state container alone (nothing at all when the section is air)
plus an immutable snapshot of the chunk’s block-entity map. A worker that
means to write many blocks at once instead brackets its work with
LevelChunkSection.acquire
and LevelChunkSection.release.
The permit, and what happens to the thread that misses it
That bracket is the permit the cast table names, and it is not a mutex.
PalettedContainer.threadingDetector is a ThreadingDetector: a one-permit
semaphore with a reporter attached. ThreadingDetector.checkAndLock tries the
permit and, on failure, records itself as the loser and then blocks. It is the
winner that notices, in ThreadingDetector.checkAndUnlock: it builds
ThreadingDetector.makeThreadingException — Accessing PalettedContainer from
multiple threads, with both stack traces — and throws it, and the loser
re-throws the same report the instant the permit comes free. Both threads die,
deliberately: an interleaved section write would be a corrupt world rather than
a crash.
Exactly one thread writes a section at a time, then — the Server thread for a
live chunk, and on the worker pool whoever holds the permit: most generation
takes it for one write at a time, and two hold it across many through
LevelChunkSection.acquire, NoiseBasedChunkGenerator, which holds every
section across its noise range, and the BulkSectionAccess that
OreFeature, its only user, holds over every section it touches until it
closes. Those hold the permit already, so they write through the unchecked
five-argument LevelChunkSection.setBlockState and
PalettedContainer.getAndSetUnchecked.
The palette and the ladder it climbs
A PalettedContainer’s whole state is one volatile record,
PalettedContainer.Data, holding a Configuration, a Palette and a
BitStorage. PalettedContainer.get reads the record once into a local and
takes no lock, because a resize swaps in a whole new record rather than
editing the old one. PalettedContainer.read from the wire is the exception
that proves it: PalettedContainer.createOrReuseData hands back the existing
record whenever the incoming bit count wants the same configuration, and the
palette and the long array are then overwritten in place — and on a client the
record overwritten can be one already in use, whenever ClientChunkCache
reuses a LevelChunk it still holds
at that position. Which record a given entry count deserves is
decided by a top-level Strategy — no longer nested inside the container —
whose Strategy.createForBlockStates and Strategy.createForBiomes are
called once per level by PalettedContainerFactory.create over
Block.BLOCK_STATE_REGISTRY and the biome registry
(registries), which is why the
global palette’s width is a runtime number and not a constant.
Strategy.getConfigurationForBitCount is the ladder, and block states and
biomes climb different ones:
| distinct values (bits needed) | block states | biomes |
|---|---|---|
| 1 (0 bits) | Strategy.ZERO_BITS → SingleValuePalette + ZeroBitStorage | the same |
| 2 … 8 (1–3 bits) | Strategy.FOUR_BITS_LINEAR → LinearPalette, already 4 bits | Strategy.ONE_BIT_LINEAR / Strategy.TWO_BITS_LINEAR / Strategy.THREE_BITS_LINEAR → LinearPalette at its own width |
| 9 … 16 (4 bits) | Strategy.FOUR_BITS_LINEAR, still 4 bits | Configuration.Global already |
| 17 … 256 (5–8 bits) | Strategy.FIVE_BITS_HASHMAP … Strategy.EIGHT_BITS_HASHMAP → HashMapPalette | Configuration.Global — there is no hashed tier for biomes |
| more | Configuration.Global → GlobalPalette, the registry’s own IdMap | Configuration.Global |
SingleValuePalette holds one value and asks for width 1 the moment a
second arrives — which for block states means jumping straight to the 4-bit
rung. LinearPalette is a flat array of 2^bits slots scanned by identity,
HashMapPalette a CrudeIncrementalIntIdentityHashBiMap, and
GlobalPalette writes nothing on the wire, maps an unknown value to id 0
and answers Palette.maybeHas with an unconditional yes. Each of the first
three calls PaletteResize.onResize when it fills, and the container is its
own
PaletteResize: PalettedContainer.onResize builds the next record,
PalettedContainer.Data.copyFrom walks every entry of the old storage through
the old palette into the new one (or, from a single value, fills), the record
is published, and only
then is the value that triggered the growth added — under
PaletteResize.noResizeExpected, which throws if a second growth were
somehow needed.
The palette is also what lets a search rule out a section it never reads.
LevelChunkSection.maybeHas puts a predicate to the palette alone, so
the scan for block-light sources behind ChunkAccess.findBlockLightSources,
and the points-of-interest check on a loaded chunk, can dismiss 4,096 blocks
with a handful of comparisons. The one rung where that
stops paying is Configuration.Global, whose Palette.maybeHas is an
unconditional yes.
SimpleBitStorage never lets an entry straddle a long: its
SimpleBitStorage.valuesPerLong is 64 divided by the width, so 4,096
entries are 256 longs at four bits, 342 at five and 512 at eight, and the
cell index comes from a multiply-shift table (SimpleBitStorage.MAGIC)
rather than a division. ZeroBitStorage answers 0 for everything and shares
one empty ZeroBitStorage.RAW array. An array of the wrong length raises
SimpleBitStorage.InitializationException, which PalettedContainer.unpack
turns into a DataResult error instead of a crash.
What packing actually buys
The two serialised forms differ. PalettedContainer.write is the wire: a
bits byte, the palette, a fixed-size long array at exactly the in-memory
width. PalettedContainer.pack is the disk (the palette and optional
data fields of a PalettedContainerRO.PackedData, behind
PalettedContainer.codecRW and
PalettedContainer.codecRO —
codecs),
and it re-encodes into a fresh HashMapPalette before asking
Strategy.getConfigurationForPaletteSize for the width — the same ladder
memory climbs. What packing recomputes is the palette: unreferenced entries
are dropped, and the smaller palette is then measured against the same rungs.
Narrower entries are the consequence, in two cases only — when the shrunken
palette lands a rung lower than the one memory was using, and when a
Configuration.Global container writes at Configuration.bitsInStorage
instead of Configuration.bitsInMemory.
PalettedContainer.unpack re-encodes on the way back only for
Configuration.Global, whose Configuration.alwaysRepack is true — every
Configuration.Simple rung reports one width for both, so its long array is
adopted exactly as it lies on disk.
The third form, and what the client is handed
Memory and disk are two of three. The wire form is
ClientboundLevelChunkWithLightPacket, whose
ClientboundLevelChunkPacketData carries the heightmaps for which
Heightmap.Types.sendToClient is true, one buffer holding every section’s
LevelChunkSection.write — empty ones included — and the block-entity update
tags. The writer pre-sizes that buffer from the sum of
LevelChunkSection.getSerializedSize and throws if
ClientboundLevelChunkPacketData.extractChunkData does not fill it to the
byte, and the reader refuses anything over two megabytes. Light rides beside
it in ClientboundLightUpdatePacketData.
The client applies the chunk through ClientPacketListener.handleLevelChunkWithLight →
ClientChunkCache.replaceWithPacketData → LevelChunk.replaceWithPacketData,
which clears the block entities, gives each section LevelChunkSection.read,
installs the raw heightmaps with ChunkAccess.setHeightmap and rebuilds the
sky-light sources, while the light it carries is queued, for
ClientLevel.pollLightUpdates to apply later in the same frame unless a
backlog is ahead of it.
Biome-only refreshes come later as
ClientboundChunksBiomesPacket → LevelChunk.replaceBiomes, and the
block-entity tags travel as
ClientboundLevelChunkPacketData.BlockEntityInfo. When a chunk is sent, and
to whom, belongs to what the client is
told.
The six heightmaps
A Heightmap is 256 entries — one per column, indexed x + z·16 — in a
SimpleBitStorage whose width is Mth.ceillog2 of height + 1, so 9 bits
for a 384-tall world, stored relative to the minimum Y.
Heightmap.getFirstAvailable is the first free Y and Heightmap.getHighestTaken
the one below it. Heightmap.primeHeightmaps fills several types in a
single top-down column scan (starting from the deprecated-for-removal
ChunkAccess.getHighestSectionPosition) into maps that
ChunkAccess.getOrCreateHeightmapUnprimed makes on demand. Heightmap.update
is the incremental path, raising the height when an opaque block is placed
at or above it and rescanning downward only when the block that turned
transparent was the top one.
| type | opaque means | usage | saved | sent |
|---|---|---|---|---|
Heightmap.Types.WORLD_SURFACE_WG | not air | Heightmap.Usage.WORLDGEN | proto only | no |
Heightmap.Types.WORLD_SURFACE | not air | Heightmap.Usage.CLIENT | yes | yes |
Heightmap.Types.OCEAN_FLOOR_WG | in BlockTags.BLOCKS_MOTION_IN_HEIGHTMAP | Heightmap.Usage.WORLDGEN | proto only | no |
Heightmap.Types.OCEAN_FLOOR | in the same tag | Heightmap.Usage.LIVE_WORLD | yes | no |
Heightmap.Types.MOTION_BLOCKING | in that tag, or holds fluid | Heightmap.Usage.CLIENT | yes | yes |
Heightmap.Types.MOTION_BLOCKING_NO_LEAVES | in BlockTags.BLOCKS_MOTION_IN_HEIGHTMAP_NO_LEAVES, or holds fluid | Heightmap.Usage.CLIENT | yes | yes |
Which of the six a chunk carries follows its status:
ChunkStatus.heightmapsAfter is the two _WG maps through
ChunkStatus.BIOMES and ChunkStatus.FINAL_HEIGHTMAPS — the other four —
from ChunkStatus.TERRAIN on, and a LevelChunk is constructed with
exactly those four. A ProtoChunk primes any of its status’s maps that are
missing the first time a block is written. What is saved, though, is not
Heightmap.Types.keepAfterWorldgen: the saver writes every heightmap the
chunk holds, so a proto chunk saves its two _WG maps along with any it
has primed since, and a LevelChunk saves the four it was built with and any
asked for since, which ChunkAccess.getHeight primes on demand. Separately
and privately,
ChunkAccess.skyLightSources is a second 256-entry bit storage — a
ChunkSkyLightSources — that only the sky-light engine reads
(the sky column is a table, not a flood).
What placing a block actually does
LevelChunk.setBlockState is the ordinary write path into a live chunk, and its
order matters more than any single step in it. Twelve steps, of which three
consult the caller’s flag word — and one of those three has only a part of
itself skipped. The bits are enumerated in block update
flags:
| in order | what happens | when it is skipped |
|---|---|---|
| 1 | the section is fetched and its emptiness remembered | air into an all-air section returns null immediately |
| 2 | LevelChunkSection.setBlockState writes the palette entry and moves all four counters | — |
| 3 | the old state is compared to the new | identical state returns null, and nothing below runs |
| 4 | all four heightmaps take Heightmap.update | — |
| 5 | if the section’s emptiness flipped: LevelLightEngine.updateSectionStatus and ChunkSource.onSectionEmptinessChanged | when it did not flip |
| 6 | if LightEngine.hasDifferentLightProperties: ChunkSkyLightSources.update, then LevelLightEngine.checkBlock | when opacity and emission are unchanged and neither state uses a shape for light occlusion |
| 7 | the old block entity is dropped, preceded on the server by BlockEntity.preRemoveSideEffects | when the block did not change, had no block entity, or BlockBehaviour.BlockStateBase.shouldChangedStateKeepBlockEntity — the side effects alone are skipped on the client and under Block.UPDATE_SKIP_BLOCK_ENTITY_SIDEEFFECTS |
| 8 | BlockBehaviour.BlockStateBase.affectNeighborsAfterRemoval | when the block did not change and the new one is no BaseRailBlock, off the server, or without Block.UPDATE_NEIGHBORS and not moved by a piston |
| 9 | the section is re-read — if step 8 changed the block again, the call returns null | — |
| 10 | BlockBehaviour.BlockStateBase.onPlace | on the client, or under Block.UPDATE_SKIP_ON_PLACE |
| 11 | the new block entity is created or re-validated, and its ticker rebound | when the new state has no block entity, or step 10 changed the block |
| 12 | LevelChunk.markUnsaved | — |
One step there is easy to misread. Step 9 exists because step 8 runs arbitrary
block code that may write the same position again, and
LevelChunk.setBlockState will not claim a placement step 8 took from it —
which is the sharpest thing this write path says about itself: it is the only
step whose whole job is to notice that the world moved underneath it. Step 8’s
own place in the wider update story, and the shape updates and redstone
notifications that fire only after the chunk returns, are told in back in
Level.setBlock’s
tail.
ProtoChunk.setBlockState is the same idea with everything live stripped
out: section write, light only past ChunkStatus.INITIALIZE_LIGHT, the
status’s heightmaps updated (and any absent primed after the write), and no
block entity,
no BlockBehaviour.BlockStateBase.onPlace, no neighbours at all.
What step 11 leaves behind, and what the chunk goes on holding
The ticker step matters to a chunk’s anatomy for one reason: the handle it
creates belongs to the chunk, not to the block entity in it.
LevelChunk.addAndRegisterBlockEntity puts a wrapper in
LevelChunk.tickersInLevel, and the level’s flat ticker list holds that: a
block entity swapped in place, or kept through a state change, is rebound into
the same wrapper, and one that is removed takes its wrapper out of the map and
rebinds it to nothing, which the level’s list then drops. What the wrappers
are and how the level’s list prunes itself is told in loaded is not enough to
tick.
The other thing the chunk goes on holding is a debt from generation (and, for
a chunk from an old save, from its upgrade), paid when
the chunk starts ticking:
LevelChunk.postProcessGeneration replays the post-processing offsets,
promotes every pending block entity and applies UpgradeData.upgrade.
Pending is the word to take seriously. A block entity placed during
generation that nothing has asked for since, every one in a half-generated
chunk read back from disk, and one saved while still pending are not
BlockEntitys yet (a structure’s loot chest is made live when its loot table
is set, and a full chunk read from disk makes the rest live as it loads); each
is a CompoundTag in
ChunkAccess.pendingBlockEntities (block
entities). Any call to
LevelChunk.getBlockEntity promotes one through
LevelChunk.promotePendingBlockEntity on the first touch, whatever the
LevelChunk.EntityCreationType asked for; LevelChunk.postProcessGeneration
promotes the rest in bulk when the chunk starts ticking. Of the three creation
types only
LevelChunk.EntityCreationType.IMMEDIATE and
LevelChunk.EntityCreationType.CHECK have callers left in the game —
LevelChunk.EntityCreationType.QUEUED has none.
Where to look
In the order the write takes them: ChunkAccess · LevelChunk.setBlockState ·
LevelChunkSection.setBlockState · PalettedContainer.onResize ·
Strategy.createForBlockStates · SimpleBitStorage · Heightmap.Types.
Then the shapes a chunk is not: ProtoChunk.setBlockState ·
ImposterProtoChunk · ChunkStatusTasks.full. And the two serialised forms:
PalettedContainer.pack · ClientboundLevelChunkPacketData.extractChunkData.
Every field no page names is in the class
index.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Tickets and loading
Verified against Minecraft 26.3 · Part IV · A player walks one block east across a chunk boundary, and a column of chunks thirteen past the edge of view is asked for.
A player standing at the eastern edge of a chunk takes one step. On the
server, ChunkMap.move notices that the player’s section changed, and
before the tick is out a column of twenty-one chunks to the east has been
asked for. The nearest of them was already generated, lit and running its
scheduled ticks, and now gets its random ticks and its entities as well. The
furthest, thirteen chunks
past the ticket that
asked for it, will exist only as a ChunkHolder at level 44, allowed no
further than ChunkStatus.STRUCTURE_STARTS and never becoming a
LevelChunk at all. Nothing in that machinery ever asks for a
chunk because it is loaded — even ChunkLevel.isLoaded is a question
about a number. Everything asks for a chunk at a level, and the ticket
system decides what the level means. That is the whole design, and it has
one consequence a player can see: there are two graphs reading one ticket
store, and they answer different questions — so a chunk can be
FullChunkStatus.ENTITY_TICKING by every measure the holder knows and tick
nothing, because render distance is how far you can see and simulation
distance is how far the world is alive.
The cast
| class | what it decides | thread |
|---|---|---|
TicketStorage | which tickets exist on which chunk, and which survive a restart — it is the chunk_tickets SavedData | Server |
DistanceManager | owns both graphs, the two player-radius trackers and the four-slot throttle | Server |
LoadingChunkTracker · SimulationChunkTracker | the two graphs: ChunkTrackers over DynamicGraphMinFixedPoint, one level higher per ring | Server |
ChunkLevel | the number line — which level means which FullChunkStatus and which generation status | static |
ChunkHolder | one chunk’s level and its three futures | Server; futures complete on workers, confirmations hop back |
ChunkMap | the holders (an updating map and a visible clone), each player’s ChunkTrackingView, unloads | Server; workers read the visible map |
ServerChunkCache | the level’s ChunkSource: the tick slot, the synchronous ServerChunkCache.getChunk, and a BlockableEventLoop pinned to the Server thread | Server |
PlayerChunkSender | the per-player batches, paced by the client’s acknowledgements | Server |
From a ticket to a future
flowchart TD
SRC["a ticket source: a player, a portal, a pearl, the dragon, /forceload"]
TS["TicketStorage: one ticket on one chunk"]
LCT["LoadingChunkTracker floods out to level 45"]
SCT["SimulationChunkTracker floods out to level 33"]
UCS["ChunkMap.updateChunkScheduling makes the holders"]
UF["ChunkHolder.updateFutures arms the three futures"]
RANGE["DistanceManager.inBlockTickingRange,<br/>DistanceManager.inEntityTickingRange"]
ALIVE["the chunk is alive: it exists, and it ticks"]
SRC --> TS
TS -- "FLAG_LOADING" --> LCT
TS -- "FLAG_SIMULATION" --> SCT
LCT --> UCS
UCS --> UF
SCT --> RANGE
UF --> ALIVE
RANGE --> ALIVE
One ticket, two graphs, and a chunk that is only alive where both arms agree: the left arm decides what a chunk may become and the right one whether it does anything, and neither consults the other. A chunk the left arm carries to block-ticking level and the right one does not exists, is lit and is sent, and is inert.
The figure is the page. A ticket lands on one chunk; each graph the ticket’s flags name floods outward from it; the loading graph’s levels decide which holders exist and what futures they arm; the simulation graph’s levels decide what ticks. The rest of the page is one section per decision on that path — and the four statuses the left arm climbs are the next figure’s.
What a ticket asks for
A Ticket is a TicketType, a Ticket.ticketLevel and a countdown,
Ticket.ticksLeft. Its identity is the pair (type, level): there is no key
object and no owner, and re-adding an identical ticket only
Ticket.resetTicksLeft. The type is a record of a timeout and a set of
flags, registered in BuiltInRegistries.TICKET_TYPE, and the flags say what
the ticket does: TicketType.FLAG_LOADING feeds the loading graph,
TicketType.FLAG_SIMULATION the simulation graph, TicketType.FLAG_PERSIST
writes it to disk, TicketType.FLAG_KEEP_DIMENSION_ACTIVE stops the level’s
empty-tick countdown and TicketType.FLAG_CAN_EXPIRE_IF_UNLOADED lets its
countdown run even while the chunk it asked for is still loading. There are
exactly nine
types, and every reason a chunk is ever loaded is one of them:
| type | timeout | loads | simulates | keeps dimension active | persists | who adds it |
|---|---|---|---|---|---|---|
TicketType.PLAYER_LOADING | — | ✓ | DistanceManager.PlayerTicketTracker, one per chunk within the server’s view distance | |||
TicketType.PLAYER_SIMULATION | — | ✓ | ✓ | DistanceManager.addPlayer, the player’s own chunk | ||
TicketType.FORCED | — | ✓ | ✓ | ✓ | ✓ | /forceload, and a game test over its structure, via TicketStorage.updateChunkForced |
TicketType.PORTAL | 300 | ✓ | ✓ | ✓ | ✓ | Entity on portal travel, radius 3 |
TicketType.ENDER_PEARL | 40 | ✓ | ✓ | ✓ | ServerPlayer, the pearl’s chunk, radius 2 | |
TicketType.DRAGON | — | ✓ | ✓ | EnderDragonFight, radius 9 | ||
TicketType.PLAYER_SPAWN | 20 | ✓ | PrepareSpawnTask during configuration, radius 3 | |||
TicketType.SPAWN_SEARCH | 1 | ✓ | PlayerSpawnFinder | |||
TicketType.UNKNOWN | 1 | ✓ | a synchronous ServerChunkCache.getChunk that may generate, at the level for its target status |
Two things in that table are easy to read past. The ticket that keeps a
dimension alive is the player’s simulation ticket, not the loading
tickets: TicketStorage.shouldKeepDimensionActive feeds
ServerChunkCache.hasActiveTickets, which resets ServerLevel.emptyTime,
the counter that past 300 makes a dimension skip its entity loop, its block
entities and the dragon fight (the level
tick).
And only
two types come back after a restart: TicketStorage.packTickets writes the
types that TicketType.persist, so forced and portal tickets are in the
dimension’s chunk_tickets file and everything else evaporates. On
shutdown TicketStorage.deactivateTicketsOnClosing parks every ticket
except TicketType.UNKNOWN in TicketStorage.deactivatedTickets, the
persisting ones are written from there, and on the next start
TicketStorage.activateAllDeactivatedTickets replays what the file held
during MinecraftServer.prepareLevels.
Which players place the two player tickets at all is asked again on every
move. ChunkMap keeps its players in a PlayerMap that records each as
ignored or not (PlayerMap.ignorePlayer, PlayerMap.ignored), and
ChunkMap.move compares that record with a fresh ChunkMap.skipPlayer — a
spectator, unless GameRules.SPECTATORS_GENERATE_CHUNKS says otherwise. So,
with that rule off, entering or leaving spectator mode takes a player out of
the distance manager,
or puts them back, at their next move.
The number line
ChunkLevel is the scale every ticket is measured on, and it runs down: the
lower a chunk’s level, the further the server takes it. Its thresholds are
declared and its ceiling is derived. ChunkLevel.byStatus gives 31 for
FullChunkStatus.ENTITY_TICKING, 32 for FullChunkStatus.BLOCK_TICKING
and 33 for FullChunkStatus.FULL. Above 33 a chunk is
FullChunkStatus.INACCESSIBLE but still generating: the FULL step of
ChunkPyramid.GENERATION_PYRAMID needs a neighbourhood of eleven
(ChunkLevel.RADIUS_AROUND_FULL_CHUNK, computed from the pyramid), so
ChunkLevel.MAX_LEVEL is 44 and ChunkLevel.generationStatus maps
34 … 44 onto ever-earlier ChunkStatuses — but not one per level:
ChunkStatus.INITIALIZE_LIGHT at 34, ChunkStatus.TERRAIN at 35,
ChunkStatus.BIOMES at 36, and ChunkStatus.STRUCTURE_STARTS for all eight
of 37 … 44. ChunkStatus.FEATURES is on that list nowhere. Level 45 means no
holder. Change the pyramid and the loading radius changes with it.
Thirteen chunks past a level-31 ticket get a holder: two rings to reach level 33, and eleven more because FULL needs that many neighbours generated.
Two graphs, one store
Both graphs are ChunkTrackers over the same TicketStorage, but each
reads only the tickets whose flags name it, and each is asked a different
kind of question:
| the question | who asks it | which graph answers |
|---|---|---|
| does a holder exist, and how far may it generate | ChunkMap.updateChunkScheduling, GenerationChunkHolder.updateHighestAllowedStatus | loading |
| may this chunk be sent to a player | ChunkMap.onChunkReadyToSend, from the level-32 future | loading |
| do its blocks and fluids tick | ServerLevel.shouldTickBlocksAt | simulation |
| do its entities tick | the entity loop, through DistanceManager.inEntityTickingRange | simulation |
| which chunks does the level walk for random ticks | ChunkMap.forEachBlockTickingChunk, a wrapper over DistanceManager.forEachEntityTickingChunk | simulation |
| may mobs spawn here | DistanceManager.naturalSpawnChunkCounter, feeding ChunkMap.anyPlayerCloseEnoughForSpawning | neither: a fixed radius-8 tracker of its own |
The last row is the third radius, and it is the only one no setting moves:
the tracker’s radius of 8 is a constant. It is not the only gate, though —
ChunkMap.collectSpawningChunks keeps a candidate only if the holder has a
ticking chunk and some non-spectating player is within 128 blocks of it. And
the two graphs have different sizes: the loading graph
runs 0 … 45 and the simulation graph 0 … 33, where 33 is not a ticking level
but the tracker’s word for no simulation ticket at all —
SimulationChunkTracker.setLevel drops the entry at 33 and the map answers
33 for anything absent. A TicketType.PLAYER_LOADING ticket puts a chunk at level 31 in the
loading graph — FullChunkStatus.ENTITY_TICKING by holder status, all three
futures armed — and contributes nothing to the simulation graph, which is
why the far edge of a large render distance is generated, lit, sent and
inert.
The four statuses
The left arm of that figure climbs a ladder of four, and the ladder is not symmetric: going up it costs a future and coming down it costs nothing.
stateDiagram-v2
[*] --> INACCESSIBLE : level 44 or below, a holder is made
INACCESSIBLE --> FULL : level 33, ChunkHolder.fullChunkFuture succeeds
FULL --> BLOCK_TICKING : level 32, ChunkHolder.tickingChunkFuture succeeds
BLOCK_TICKING --> ENTITY_TICKING : level 31, ChunkHolder.entityTickingChunkFuture succeeds
ENTITY_TICKING --> BLOCK_TICKING : level above 31, immediate
BLOCK_TICKING --> FULL : level above 32, immediate
FULL --> INACCESSIBLE : level above 33, immediate
INACCESSIBLE --> [*] : level above 44, the holder is dropped
note left of INACCESSIBLE : every promotion waits for a future, every demotion is immediate
The four values of FullChunkStatus, not the ten of ChunkStatus, read as
two columns of arrows: going down the page costs a future and can take many
ticks, coming back up costs nothing and happens inside the one call. That
asymmetry
is why a chunk stops ticking the instant you walk away and starts again slowly.
ChunkHolder.updateFutures compares ChunkLevel.fullStatus of
ChunkHolder.oldTicketLevel (what the futures reflect) with that of
ChunkHolder.ticketLevel (what the graph last said). Each threshold crossed
upward arms one future — ChunkMap.prepareAccessibleChunk at 33,
ChunkMap.prepareTickingChunk at 32, ChunkMap.prepareEntityTickingChunk
at 31 — and a chunk that goes from 45 to 31 in one update arms all three at
once. Every one is wrapped by ChunkHolder.scheduleFullChunkPromotion, so
that success fires ChunkMap.onFullChunkStatusChange on the Server thread,
and chained into ChunkHolder.addSaveDependency, so the chunk cannot be
saved or unloaded mid-promotion. Under the hood each future is a
GenerationChunkHolder.scheduleChunkGenerationTask — the task claims its 529
before it runs
anything.
What those futures carry is a ChunkResult — a two-case result type of
ChunkResult.Success or ChunkResult.Fail, the second holding a string
supplier rather than an exception. Every one of the holder’s three futures is
completed with one, and ChunkHolder.UNLOADED_LEVEL_CHUNK is simply the shared
failure whose message is Unloaded level chunk. A chunk that never arrives is
a value the waiter is handed, not an error, until ServerChunkCache.getChunk,
asked to load or generate, turns it into one.
Demotion is the asymmetry. A threshold crossed downward completes the
matching future with ChunkHolder.UNLOADED_LEVEL_CHUNK and
ChunkHolder.demoteFullChunk fires the status change now, cancelling any
promotion still pending. Entities in a chunk whose level rose past 31 stop
ticking in the same update; nothing waits for a worker.
What each promotion means when it lands: at FULL,
PersistentEntitySectionManager.updateChunkStatus makes the chunk’s
entities Visibility.TRACKED and queues their data to load (Part VI). At
BLOCK_TICKING the continuation runs LevelChunk.postProcessGeneration,
ServerLevel.startTickingChunk — which is LevelChunk.unpackTicks, the
saved scheduled ticks becoming real — and ChunkMap.onChunkReadyToSend. At
ENTITY_TICKING, PersistentEntitySectionManager.startTicking puts the
entities on the EntityTickList.
When the graphs run
All of it on the Server thread, in three slots (and a few one-off callers — a save, a load with a radius, the load counter at start-up and at each join):
- The tick.
ServerChunkCache.tick, from the level tick, runsTicketStorage.purgeStaleTicketsand thenServerChunkCache.runDistanceManagerUpdates. - Idle time. When the server’s own queue has nothing to run and the
server is ahead of its schedule, blocked or sprinting,
MinecraftServer.pollTaskInternaloffers the levels’ServerChunkCache.MainThreadExecutor.pollTaska turn, one after another until one runs something; that poll runs the distance updates first and, if they did any work, returns at once. - A synchronous ask.
ServerChunkCache.getChunkfrom anywhere on the Server thread checks a four-entry cache, thenServerChunkCache.getChunkFutureMainThreadadds aTicketType.UNKNOWNticket and blocks on the future it arms.
The second slot has a priority buried in it: the light schedule and the one
queued chunk task happen only on a poll where the graphs were already
settled, so propagation does not share the queue with chunk work — it starves
it until quiescent. The third has an ordering: if ServerChunkCache.chunkAbsent,
the distance updates run synchronously so that the holder exists inside the
call, and BlockableEventLoop.managedBlock then waits. The Server thread does
not simply sleep on a chunk: it runs the chunk source’s own tasks while it
waits, and off-thread callers are bounced to the Server thread and joined.
Inside DistanceManager.runAllUpdates the order is fixed: the spawn
counter, the simulation tracker, the player ticket tracker, the loading
tracker, and then two passes over DistanceManager.chunksToUpdateFutures
— GenerationChunkHolder.updateHighestAllowedStatus for every changed
holder first, ChunkHolder.updateFutures for every holder second — because
a holder’s range future depends on its neighbours’ allowed status. Nothing
here adds a ticket from a worker. The throttle’s
ThrottlingChunkTaskDispatcher is built over a TaskScheduler wrapping
DistanceManager.mainThreadExecutor, so the ticket task it releases runs on
the Server thread; only the dispatcher’s own priority-queue bookkeeping
runs on the worker pool.
The walk east
View distance 10, simulation distance 10.
sequenceDiagram
participant SGPL as ServerGamePacket<br/>ListenerImpl
participant CM as ChunkMap
participant DM as DistanceManager
participant TS as TicketStorage
participant CH as ChunkHolder
participant PCS as PlayerChunkSender
SGPL->>CM: move: the section changed
CM->>DM: removePlayer(old) then addPlayer(new)
DM->>TS: PLAYER_SIMULATION ticket moves, level 21
CM->>SGPL: ClientboundSetChunk<br/>CacheCenterPacket
Note over DM: DistanceManager.<br/>runAllUpdates, this tick<br/>or the next idle poll
DM->>DM: the simulation graph floods, entity range 10, block range 11
DM->>DM: DistanceManager.<br/>PlayerTicketTracker:<br/>21 in, four at a time,<br/>21 out at once
DM->>TS: PLAYER_LOADING added at 31 east as the throttle lets each through, removed west as each is released
DM->>CM: the loading graph floods out to level 44
DM->>CH: updateFutures: each eastern column a level lower, the new one 32 to 31
CH-->>CM: a later tick, at BLOCK_TICKING: onChunkReadyToSend
CM->>PCS: markChunkPendingToSend, per player in view
CH-->>DM: ENTITY_TICKING done, the throttle slot freed
PCS->>SGPL: send: batch start, nearest first, batch finish
One step east, from the packet to a chunk on the wire: everything above the note bar happens inside handling one packet, where, of its chunks, the client hears only the new centre and a forget for each delivered chunk that left the view, and everything below it waits for the distance manager to be asked to run its updates. The two dotted arrows are the only two things that come back, and both come back late.
The move is ServerGamePacketListenerImpl.handleMovePlayer →
ServerGamePacketListenerImpl.handlePlayerPositionChange →
ServerChunkCache.move → ChunkMap.move, which updates every
ChunkMap.TrackedEntity for the player and then compares
ServerPlayer.getLastSectionPos with the new SectionPos.
DistanceManager.removePlayer finds the old chunk’s
DistanceManager.playersPerChunk set empty and removes the
TicketType.PLAYER_SIMULATION ticket; DistanceManager.addPlayer mirrors
it at DistanceManager.getPlayerTicketLevel, 31 minus the simulation
distance. TicketStorage.addTicket compares the new ticket’s level against the
lowest each graph the flags name already had, and tells the registered
TicketStorage.ChunkUpdated listener only when the new one is lower; the
removal path is the one that recomputes a minimum. Either way the listener
only queues the change; the flood happens in
DistanceManager.runAllUpdates.
The simulation graph settles first and needs no futures and no IO: level 31
out to distance 10, 32 at 11, and from that moment
DistanceManager.inEntityTickingRange and
DistanceManager.inBlockTickingRange answer differently for the far
western edge, whose block ticking has already stopped. Then
DistanceManager.PlayerTicketTracker.runAllUpdates floods its own radius-32
graph. For each chunk newly within DistanceManager.PlayerTicketTracker.haveTicketFor
it submits a task to the dispatcher at priority = distance; for each chunk
that left, it releases the slot with a continuation that removes the
TicketType.PLAYER_LOADING ticket. The dispatcher lets four through at
a time; each adds a ticket at DistanceManager.PLAYER_TICKET_LEVEL, 31, and
records the key in DistanceManager.ticketsToRelease. The loading graph
floods from it — 31 at the chunk, 32 and 33 in the rings, then 34 … 44
eleven chunks further east — and LoadingChunkTracker.setLevel →
ChunkMap.updateChunkScheduling creates a ChunkHolder for every chunk
whose level dropped to 44 or below, or resurrects one from
ChunkMap.pendingUnloads. Finally ChunkMap.promoteChunkMap publishes the
new holders to the visible map and ServerChunkCache.clearCache.
The release is what makes sprinting outrun the loader by design.
DistanceManager.runAllUpdates does not wait for the entity-ticking
future: on a pass where nothing else needed updating, it hangs a
continuation on each pending key’s future and clears the set. A busy tick
defers every release, and the slot frees whenever the already-attached
future completes, so at most four TicketType.PLAYER_LOADING tickets are in flight at once, and
they are the four nearest waiting.
The west unloads without a timeout. The removed loading tickets raise every
western column a level: those crossing 31, 32 and 33 demote at once, their
futures completing with ChunkHolder.UNLOADED_LEVEL_CHUNK, and the one pushed
past 44 goes to ChunkMap.toDrop → the next
ChunkMap.tick with the time supplier runs ChunkMap.processUnloads →
ChunkMap.scheduleUnload, save and ServerLevel.unload
(a chunk nobody needs any
more). If a ticket re-adopts
the chunk first,
ChunkMap.updateChunkScheduling pulls it back out of
ChunkMap.pendingUnloads and the unload task finds nothing to do.
Which chunks a player is owed, and what makes one eligible
Sending is Part IX’s (chunks arrive on a loop the client paces has the batches, the acknowledgement limit and the rate the client asks for). What belongs here is the other half: which chunks are in a player’s set at all, and what makes one eligible to go — because both are answers the ticket system gives.
| the moment | what the ticket system decides | the gate |
|---|---|---|
| the player crosses a section boundary | the two crescents: which chunks entered the view and which left | ChunkMap.updateChunkTracking returns early on the same centre and view distance, and ChunkMap.applyChunkTrackingView sends ClientboundSetChunkCacheCenterPacket only when the centre moved |
| a chunk enters the view | ChunkMap.markChunkPendingToSend, but only for a chunk that already exists | ChunkMap.getChunkToSend wants a ticking chunk; a fresh one waits for its promotion instead |
| a chunk reaches BLOCK_TICKING | ChunkMap.onChunkReadyToSend makes it pending for every player whose view holds it | ChunkHolder.sendSync, which starts complete; the one thing that delays it is ChunkMap.waitForLightBeforeSending, whose single caller is EnderDragonFight after building the exit portal |
| a chunk leaves the view | ClientboundForgetLevelChunkPacket | only if it was not still pending, and only to a living player — you cannot forget what was never delivered |
| a block changes in a chunk not yet delivered | nothing at all | ChunkMap.isChunkTracked is false while the chunk sits in PlayerChunkSender.pendingChunks; the full chunk will carry it |
| the settings change | a radius moves, for every player at once | PlayerList.setViewDistance and PlayerList.setSimulationDistance, the second swapping every simulation ticket’s level through TicketStorage.replaceTicketLevelOfType |
The third row is the join between the two systems, and it is why this table is here: a chunk becomes sendable at the loading graph’s level 32, the number at which its blocks would tick, but ticking asks the simulation graph, so beyond simulation distance a player is sent chunks that do not tick.
Two shapes hide in that table. The view is a rounded square, not a disc:
ChunkTrackingView.isWithinDistance subtracts a buffer of two from each
axis before squaring, so at view distance 10 it reaches eleven chunks
along the axes and nine on the diagonal. And view distance shapes what is
sent, not what is loaded: ChunkMap.getPlayerViewDistance clamps a
player’s request to the server’s, but the ticket tracker’s radius comes from
the server view distance through DistanceManager.updatePlayerTickets.
Singleplayer skips the size cap and not the pacing, which is Part IX’s to
explain.
When a ticket dies
| the ticket | dies when |
|---|---|
| no timeout — player, forced, dragon | its source removes it: the player leaves the chunk, /forceload remove (or a game-test batch finishing, which unforces every forced chunk in its level), the fight ends |
timed and TicketType.canExpireIfUnloaded — only TicketType.UNKNOWN | the countdown runs every tick regardless, so it can expire before the chunk it asked for loads; ServerChunkCache.addTicketAndLoadWithRadius refuses such types for that reason |
| timed, everything else — portal, pearl, spawn | the countdown runs only while there is no holder at all or the holder ChunkHolder.isReadyForSaving; a portal ticket never expires under a chunk still loading, and one over a chunk nothing tracks expires normally |
| the server stops | every type but TicketType.UNKNOWN is parked; only the persisting types reach disk, and only they are replayed on the next start |
TicketStorage.purgeStaleTickets runs from ServerChunkCache.tick and
applies exactly those rules — once a tick, unless the level is frozen and
chunk ticking is on, in which case it does not run at all.
Questions players ask
Why does turning my render distance down not help the server? Because the server loads to its view distance, not yours. Your request only clamps what you are sent.
Why does the world load in a square? Because the graph floods in Chebyshev rings, and every ring is a square. What you are sent is not quite one: the tracking view is a square with its corners cut by the buffer-of-two test above.
Why do mobs spawn where I did not expect? The spawn set is a fixed radius of eight around each player, on a tracker that reads neither graph and no setting.
Why does a portal keep its chunks after I have gone? A
TicketType.PORTAL ticket lasts 300 ticks, persists across restarts with
its remaining Ticket.ticksLeft, and only counts down while its chunk is
saveable.
Do spectators load chunks? By default, yes:
GameRules.SPECTATORS_GENERATE_CHUNKS is on. Turn it off and a spectator
places no player ticket — they are sent the chunks other tickets already hold
at block-ticking level and, once in the world, load none, not even from disk.
For a 1.21-era reader. There is no LIGHT, PLAYER, START or POST_TELEPORT ticket, and there is no forced-chunks file:
TicketStorage(inworld/level, notserver/level) is the saved data now. The two graphs are separate top-level classes, not inner classes ofDistanceManager, and the wholeChunkHoldergeneration half is a superclass,GenerationChunkHolder.
Where to look
Start where a ticket does: TicketType · TicketStorage.addTicket ·
ChunkLevel.byStatus. Then the two graphs and the pass that runs them:
LoadingChunkTracker · SimulationChunkTracker ·
ChunkTracker.computeLevelFromNeighbor · DistanceManager.runAllUpdates.
Then what a level change does to one chunk: ChunkMap.updateChunkScheduling ·
ChunkHolder.updateFutures · ChunkMap.prepareTickingChunk. And two of the
ways in from outside: ChunkMap.move for a player,
ServerChunkCache.getChunk for a synchronous ask.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
The chunk generation pipeline
Verified against Minecraft 26.3 · Part IV · A ticket asks for one chunk at FULL, and the server claims five hundred and twenty-nine of them before it runs a single step.
A player walks east, a loading ticket lands on the chunk that just entered
view, and ChunkHolder.updateFutures asks that chunk for ChunkStatus.FULL.
The request names that chunk and the eight around it, and nothing further out.
But FULL is the last of ten
steps, and the steps below it read — and one of them writes — the chunks
around the one being built, so the first thing ChunkGenerationTask.create
does is walk out to Chebyshev distance 11 and take a claim on every holder in
that square: asking for one chunk asks for 529 of them, and the eleven
rings it claims will never, on this task’s account, become chunks you could
stand on. Eleven is not a tuning constant. It is the index of the last entry of
ChunkStep.accumulatedDependencies for the FULL step — a twelve-entry list,
one per ring from the centre out: ChunkLevel.RADIUS_AROUND_FULL_CHUNK reads
that radius off the pyramid, and ChunkLevel.MAX_LEVEL, 44, is 33 plus it.
Change the pyramid and the world’s loading radius changes with it.
The cast
| class | what it decides | thread |
|---|---|---|
ChunkStatus | the ten names and their order, each with the chunk type and the heightmaps it leaves — no task, no radius, no work | static, a BuiltInRegistries.CHUNK_STATUS entry |
ChunkPyramid | the two step lists — one for generating, one for loading — that say what each status needs and what runs it | static |
ChunkStep | one status’s direct and accumulated dependencies, its block-state write radius, and its body | static |
ChunkGenerationTask | one (chunk, target) walk: which layer is in flight, which pyramid it is using, and when to yield | the worldgen executor |
GenerationChunkHolder | one chunk’s ten futures, its ticket-derived ceiling, and the compare-and-set that runs each step exactly once | any — its mutable fields are atomic or volatile |
ChunkMap | makes the tasks, owns both executors, and turns the EMPTY step into a disk read | Server, but ChunkMap.applyStep runs on whatever thread reached it |
ChunkTaskDispatcher | which chunk’s batch of work the executor gets next, and re-sorts the queue when tickets move | its own single-file queue on the worker pool |
WorldGenRegion | what a running step may read and what it may write, checked per call | the thread running the step |
The pyramid, drawn
The pipeline is ten steps in a fixed order, and three facts about each of them — how wide it is swept, where it runs, and how far from its own chunk it may write. Those are three columns, so they are a table:
| # | status | swept to | where it runs | may write |
|---|---|---|---|---|
| 1 | ChunkStatus.EMPTY | 11 | the disk read — region file on the IO lane, upgrade and parse on the pool, chunk object on the Server thread | — |
| 2 | ChunkStatus.STRUCTURE_STARTS | 11 | inline, worldgen executor | — |
| 3 | ChunkStatus.STRUCTURE_REFERENCES | 3 | inline, worldgen executor | — |
| 4 | ChunkStatus.BIOMES | 3 | forked to the worker pool, as createBiomes | — |
| 5 | ChunkStatus.TERRAIN | 2 | forked to the worker pool, as buildTerrain | 0 |
| 6 | ChunkStatus.FEATURES | 1 | inline, worldgen executor | 1 |
| 7 | ChunkStatus.INITIALIZE_LIGHT | 1 | the light executor | — |
| 8 | ChunkStatus.LIGHT | 0 | the light executor | — |
| 9 | ChunkStatus.SPAWN | 0 | inline, worldgen executor | — |
| 10 | ChunkStatus.FULL | 0 | the Server thread | — |
Six of the ten leave the worldgen executor and one of them —
ChunkStatus.EMPTY — is not worldgen work at all. The swept to column is how
wide that layer is swept when the target is FULL and the task has decided it
must generate: ChunkGenerationTask.getRadiusForLayer asks the FULL step of
whichever pyramid is in play for ChunkStep.getAccumulatedRadiusOf that status.
A task aiming lower sweeps narrower rings, and a chunk that only ever reaches
STRUCTURE_STARTS is swept at radius 0 by its own task. ChunkStatus.EMPTY is
the row to read twice: the first sweep is the loading pyramid’s radius 1, and
only a chunk that turns out to need generating is swept at 11, as the
load-or-generate section below explains.
Those radii nest, and nesting is what the word pyramid is doing. Read from the middle outward:
flowchart TD
subgraph OUT["STRUCTURE_STARTS, to 11"]
subgraph R3["BIOMES, to 3"]
subgraph R2["TERRAIN, to 2"]
subgraph R1["INITIALIZE_LIGHT, to 1"]
C["the chunk you asked for: SPAWN, then FULL"]
end
end
end
end
The pyramid seen from above: each box is a ring of neighbours, labelled with
how deep into the pipeline that ring must already be, four rings at one
instant rather than an order in time. The outer one is the number to take
away: radius 11 is 23 by 23, so one chunk reaching ChunkStatus.FULL has
claimed 529.
That is ChunkStep.accumulatedDependencies drawn: a list indexed by distance,
holding the deepest status needed at that distance. For FULL it has twelve
entries — ChunkStatus.SPAWN at distance 0, ChunkStatus.INITIALIZE_LIGHT at
1, ChunkStatus.TERRAIN at 2, ChunkStatus.BIOMES at 3, and
ChunkStatus.STRUCTURE_STARTS for every distance from 4 out to 11 — so the
radius is 11 and the neighbourhood is 529.
A ChunkStatus carries no work. It is a registry entry with an index, a
parent, a ChunkType (ChunkType.PROTOCHUNK for the first nine,
ChunkType.LEVELCHUNK for ChunkStatus.FULL) and the heightmaps that become
valid after it, ChunkStatus.heightmapsAfter. Everything else — the
dependencies, the write radius, the body — lives in the ChunkStep that
ChunkPyramid holds for that status. Each step is built from its
predecessor, so every step silently requires its own parent status at
distance 0 before it declares anything; ChunkStep.Builder.addRequirement
then widens the array outward, taking the later of the two statuses at every
distance already covered.
The generation pyramid’s declared requirements, with the parent requirement resolved in (INITIALIZE_LIGHT and FULL, like STRUCTURE_STARTS, declare nothing beyond their parent):
| step | needs | may write |
|---|---|---|
| STRUCTURE_STARTS | EMPTY at 0 | — |
| STRUCTURE_REFERENCES | STRUCTURE_STARTS from 0 out to 8 | — |
| BIOMES | STRUCTURE_REFERENCES at 0, STRUCTURE_STARTS out to 8 | — |
| TERRAIN | BIOMES within 1, STRUCTURE_STARTS out to 8 | radius 0 |
| FEATURES | TERRAIN within 1, STRUCTURE_STARTS out to 8 | radius 1 |
| LIGHT | INITIALIZE_LIGHT within 1 | — |
| SPAWN | LIGHT at 0, BIOMES at 1 | — |
The rows that do the work are the radius-1 ones: they force a neighbour to run one step ahead of the chunk being built. Four requirements in the pyramid have radius 1, and only three of them widen the accumulated list — which is the arithmetic the page’s headline number rests on, so it is worth stating as a rule.
ChunkStep.Builder.getRadiusOfParent asks a narrower question than does this
step want anything a ring out. It asks how far out this step still demands
its own immediate predecessor, and it charges that many rings to everything
the predecessor already needed. TERRAIN’s predecessor is BIOMES and TERRAIN
asks for BIOMES at radius 1, so its whole inherited list slides out by one.
SPAWN’s predecessor is LIGHT, and although SPAWN also asks for
BIOMES within 1, BIOMES is behind LIGHT: at ring 1 SPAWN is
demanding something its predecessor has already passed, which costs nothing.
The three that pay are TERRAIN wanting BIOMES, FEATURES wanting TERRAIN
and LIGHT wanting INITIALIZE_LIGHT, each one ring. Three ones on top of
STRUCTURE_STARTS out to 8 is where the 11 comes from — a radius of 11 is a
list of twelve, and the walk that claims it is 23 chunks on a side, or 529.
The 8 is the distance ChunkStatus.MAX_STRUCTURE_DISTANCE names.
The same arithmetic sets the edge of the world. ChunkPyramid.SAFETY_MARGIN_CHUNKS
is 32 plus the twelve accumulated entries plus one, doubled — 90 chunks —
subtracted from the coordinate maximum to give
ChunkPyramid.MAX_CHUNK_COORDINATE_VALUE, which ChunkPos.isValid enforces
and the GenerationChunkHolder constructor throws on. It is a guard against
arithmetic, not against players: at about 33.5 million blocks it sits three
and a half million blocks outside Level.MAX_LEVEL_SIZE, the ±30 000 000
nobody can build past anyway.
A ticket sets a ceiling, and a separate call names the target
Two different numbers reach a holder from the ticket system.
DistanceManager.runAllUpdates first gives every touched holder
GenerationChunkHolder.updateHighestAllowedStatus, which is
ChunkLevel.generationStatus of the new ticket level — the number line that
maps 34 through 44 onto ever-earlier statuses belongs to the number
line. That is a ceiling, not a
goal: GenerationChunkHolder.isStatusDisallowed gates every request against
it and hands back GenerationChunkHolder.UNLOADED_CHUNK_FUTURE for anything
above. Then ChunkHolder.updateFutures crosses FullChunkStatus.FULL,
calls ChunkMap.prepareAccessibleChunk, and that names the target:
ChunkMap.getChunkRangeFuture over the 3×3, ChunkStatus.FULL on the
centre and ChunkLevel.getStatusAroundFullChunk — INITIALIZE_LIGHT — on
the eight around it, each through GenerationChunkHolder.scheduleChunkGenerationTask.
If the ceiling later drops, GenerationChunkHolder.updateHighestAllowedStatus
fails every pending future between the new ceiling and the old with
GenerationChunkHolder.UNLOADED_CHUNK and reschedules the task at the
highest status anyone is still waiting for. Nothing is interrupted; a worker
mid-step finishes it and finds nobody listening.
The other way in is synchronous: ServerChunkCache.getChunk from the server
thread manufactures its own ticket and then blocks on the future it arms
(when the graphs run). What
matters to this page is the consequence — the thread that is blocked on
generation is also the thread finishing it, because the executor it blocks on
drains chunk work while it waits.
The task claims its 529 before it runs anything
GenerationChunkHolder.scheduleChunkGenerationTask finds no task in flight,
so GenerationChunkHolder.rescheduleChunkTask calls
ChunkMap.scheduleGenerationTask and ChunkGenerationTask.create builds the
holder set: a StaticCache2D whose radius is the generation pyramid’s
accumulated radius of ChunkStatus.EMPTY for the target — 11 for FULL —
filled by GeneratingChunkMap.acquireGeneration, the five-method interface
ChunkMap implements and the pipeline holds. That radius is taken
from the generation pyramid unconditionally, before anything has looked at
the disk, so even a chunk that turns out to be sitting complete in a region
file claims all 529 holders first.
The claim is a reference count. GenerationChunkHolder.increaseGenerationRefCount
on the first claim arms GenerationChunkHolder.generationSaveSyncFuture and
hangs it off the holder’s save dependency, so nothing in the square can be
saved or unloaded while the task lives (a chunk nobody needs any
more).
The task itself waits in ChunkMap.pendingGenerationTasks until
ChunkMap.runGenerationTasks, at the end of the same
ServerChunkCache.runDistanceManagerUpdates that created it.
Dispatch, and why the parallelism is smaller than the thread names
ChunkMap builds two ConsecutiveExecutors over the shared worker pool,
named worldgen and light, each wrapped in a ChunkTaskDispatcher. A
ConsecutiveExecutor runs one task at a time: AbstractConsecutiveExecutor.run
pops a single item, runs it under the executor’s name, and re-registers
itself on the pool. The dispatcher in front of it is a
ChunkTaskPriorityQueue of ChunkTaskPriorityQueue.PRIORITY_LEVEL_COUNT
buckets — 46, ChunkLevel.MAX_LEVEL plus two — keyed by the holder’s queue
level, and ChunkTaskDispatcher.scheduleForExecution hands over one chunk’s
runnables at a time, polling again only when they have all returned. So the
steps a dimension runs inline run single file, however many Worker-Main-n
threads the shared pool has (four threads worth
memorising sizes it, and
serialising onto a pool is what a ConsecutiveExecutor is for); what runs in
parallel is what a step hands off: the disk read’s upgrade and parse and the
biome and terrain forks on the pool, many chunks’ at once, the light work on
its own executor, and FULL on the Server thread. There is no generation
thread setting: the only
knob is the pool’s, and widening it widens those forks and everything else that
shares the pool.
One worldgen runnable executes at a time per dimension
(ChunkMap.worldgenTaskDispatcher, over a single ConsecutiveExecutor).
Overlap comes from yielding.
ChunkGenerationTask.runUntilWait returns the moment a layer holds a future
that is not yet done; ChunkMap.runGenerationTask chains a resubmit onto
that future and the executor moves to another chunk’s task at once. No worldgen thread
ever blocks waiting for a neighbour, and a task parked on a biome fork costs
nothing.
Priority is live, not fixed at submission. ChunkHolder.updateFutures ends
by telling both dispatchers through ChunkTaskDispatcher.onLevelChange, and
ChunkTaskPriorityQueue.resortChunkTasks moves work already queued into its
new bucket — at a higher priority inside the dispatcher’s own four-slot queue
than new submissions get, so “closer to a player runs first” stays true while
the player is moving. ThrottlingChunkTaskDispatcher is a subclass of the
same thing but is not worldgen: it caps how many player-view chunks the
ticket tracker may have in flight, on the Server thread.
The EMPTY step asks the only question that changes the walk
ChunkGenerationTask.scheduleNextLayer always begins with ChunkStatus.EMPTY
at the loading pyramid’s radius, which for a FULL target is 1.
ChunkMap.applyStep special-cases that status: instead of a step body it
runs ChunkMap.scheduleChunkLoad — the region read, ChunkMap.upgradeChunkTag
on the pool under upgradeChunk, SerializableChunkData.parse on the pool
under parseChunk, the POI file prefetched alongside through
SectionStorage.prefetch, then SerializableChunkData.read on the server
thread. What comes out is a ProtoChunk at whatever status the file
recorded, an ImposterProtoChunk wrapping a real LevelChunk if the file
was already at FULL, or ChunkMap.createEmptyChunk when there was no file.
The futures are not done, so the task yields and is re-entered when they land
(chunk storage).
A file that will not parse is regenerated, not skipped
There is a fourth outcome, and it rejoins the third.
SerializableChunkData.parse returning null logs missing level
data and empties the optional, so the step falls through to
ChunkMap.createEmptyChunk exactly as though the file had never existed; and
anything thrown along the way reaches ChunkMap.handleChunkLoadFailure on the
Server thread, which re-throws a JVM Error as a crash report and otherwise
reports the failure through MinecraftServer.reportChunkLoadFailure and hands
back an empty chunk. Either way the empty proto chunk fails the load test
below, so the walk generates, and the position is marked replaceable in
ChunkMap.chunkTypeCache, which lets the saver write a half-made chunk over
it — an unreadable chunk is regenerated, not skipped, and the old bytes stay
on disk until something writes over them.
Load or generate, decided per chunk and per layer
Now ChunkGenerationTask.canLoadWithoutGeneration decides, and it asks two
things. First, is the centre persisted at or past the target — for a FULL
target, FULL. Then, separately, is every chunk in the loading pyramid’s
accumulated square at or past what its distance demands: for FULL that
square is the 3×3, wanting SPAWN at the centre and INITIALIZE_LIGHT on the
ring, so the centre is checked twice and the second check is the weaker one.
If both hold, the walk stays narrow. Ten steps still run, but
ChunkPyramid.LOADING_PYRAMID gives five of them no body at all —
STRUCTURE_REFERENCES, BIOMES, TERRAIN, FEATURES and SPAWN pass
straight through. Of the five that remain, EMPTY is the
disk read ChunkMap.applyStep special-cases, and four carry a task:
ChunkStatusTasks.loadStructureStarts, which only posts the saved starts to
StructureCheck, the two light steps, and ChunkStatusTasks.full. A
loaded chunk still walks all ten steps, and it still needs its 3×3
neighbours at INITIALIZE_LIGHT before its own LIGHT step will run.
If it does not hold, ChunkGenerationTask.needsGeneration goes true and
EMPTY is scheduled a second time, now at radius 11 — reading only the
chunks the first sweep did not touch.
GenerationChunkHolder.applyStep runs GenerationChunkHolder.acquireStatusBump,
a compare-and-set on GenerationChunkHolder.startedWork from a status’s
parent to the status itself, so exactly one caller ever runs a step for a
holder and every other caller is handed the existing future.
And the choice of pyramid is made again for every chunk in every layer,
not once for the task. ChunkGenerationTask.scheduleChunkInLayer compares
that chunk’s persisted status with the layer being applied and takes the
generation pyramid only if the chunk is genuinely behind, so a generating
task’s 23×23 square routinely mixes both — which is exactly what stops
already-finished neighbours being generated a second time.
Two steps may write, and only two
ChunkStep’s default block-state write radius is −1, not 0 — so for
eight of the ten steps WorldGenRegion.ensureCanWrite fails even for the
chunk’s own column, and WorldGenRegion.setBlock logs and returns false
rather than doing anything. Only TERRAIN (radius 0) and FEATURES
(radius 1) can change a block at all. What rides on those
steps — the density functions, the material rules, the carvers, the features
and the structures they place — is Part XII’s subject
(terrain,
density functions,
structure placement). This page is the conveyor.
STRUCTURE_STARTS runs ChunkGenerator.createStructures for every chunk in
the radius-11 square that is not already past it — seed and placement state
only, no terrain — and is skipped entirely when WorldOptions.generateStructures
is off. Either way ServerLevel.onStructureStartsAvailable posts the chunk’s
starts to the Server thread. STRUCTURE_REFERENCES then records, per chunk,
which starts within eight chunks reach into it: the reason starts needed a
radius of 8 around it. BIOMES forks — ChunkGenerator.createBiomes,
which no generator overrides, puts the work on the pool under
createBiomes — so biomes always leave the worldgen executor. TERRAIN forks
only for NoiseBasedChunkGenerator, under buildTerrain — the noise fill, the
surface and the carvers in one job — and then applies the BelowZeroRetrogen
bedrock fix-ups if the chunk is being deepened and primes the four final
heightmaps with Heightmap.primeHeightmaps; FlatLevelSource and
DebugLevelSource return a completed future and stay inline. FEATURES
decorates and calls Blender.generateBorderTicks
(blending).
FEATURES is the interesting one, because a tree at a chunk edge writes into
a neighbour and nothing about that neighbour’s status says it is safe. What
makes it safe is the executor: the layer steps its chunks one at a time, and every step the dimension runs inline, decoration among them, is serialised behind the one ConsecutiveExecutor, so no two feature steps in a dimension are ever
running at once. The ordinary cross-chunk write is the plain
ChunkAccess.setBlockState, which takes and releases the section per write;
only OreFeature holds a section open across many writes, through
BulkSectionAccess (sections and their four
counters).
A read too far crashes, a read too wide only warns
Both bad accesses are caught, and they are caught differently. A read outside
the step’s ChunkStep.directDependencies — too far away, or at a status that
distance does not guarantee — throws out of WorldGenRegion.getChunk as a
crash report naming the step, the requested and actual statuses, the distance
and the whole dependency list. A read that is merely outside the write zone
is one line in the log, at error level, from WorldGenRegion.warnIfReadOutsideWriteZone, naming the
feature through WorldGenRegion.currentlyGenerating. The first is a bug in
the pyramid; the second is a bug in a feature, and the game keeps going.
Light runs on a second executor, and the task waits for it
ChunkStatusTasks.initializeLight calls ChunkAccess.initializeLightSources
and ProtoChunk.setLightEngine — from that moment the proto chunk forwards
block changes to the engine — and then hands the chunk to
ThreadedLevelLightEngine.initializeLight. ChunkStatusTasks.light follows
with ThreadedLevelLightEngine.lightChunk. Both queue through the light
ChunkTaskDispatcher onto the light ConsecutiveExecutor, and the future
each returns is completed by a later task on that same executor, so the
generation task genuinely parks here (what actually kicks
it).
Both are passed a lighted flag from ChunkStatusTasks.isLighted, and what
it decides — that light saved on disk is re-enabled rather than recomputed —
is told in lit before you ever see
it.
FULL is assembled on the server thread
ChunkStatusTasks.full is scheduled exactly like the other nine steps, but
its body is a supplyAsync on WorldGenContext.mainThreadExecutor — the
ServerChunkCache.MainThreadExecutor, a BlockableEventLoop pinned to the
Server thread. There are two shapes it can take. If the chunk is already an
ImposterProtoChunk, because the file held a finished chunk, it unwraps the
LevelChunk inside and replaces nothing. Otherwise a LevelChunk is built
from the ProtoChunk, sharing its sections, and
GenerationChunkHolder.replaceProtoChunk rewrites slots 0 through 8 of the
holder’s future array to an ImposterProtoChunk over it with writes
disallowed — every slot checked, and an exception thrown if any of them is not
a ProtoChunk or was changed by another thread in the meantime, which
GenerationChunkHolder.applyStep relays as a crash.
Then the chunk becomes part of the world, in order: LevelChunk.setFullStatus
wired to the holder, LevelChunk.runPostLoad turning ProtoChunk.getEntities
into real entities through ServerLevel.addWorldGenChunkEntities,
LevelChunk.setLoaded, LevelChunk.registerAllBlockEntitiesAfterLevelLoad,
LevelChunk.registerTickContainerInLevel and LevelChunk.setUnsavedListener.
GenerationChunkHolder.completeFuture publishes it at FULL.
Nothing here crosses the network. A chunk reaches a client only after the
separate promotion to FullChunkStatus.BLOCK_TICKING (which chunks a player
is owed).
Release, and what the ring is left as
ChunkGenerationTask.runUntilWait comes round, finds the scheduled status
equal to the target, and calls ChunkGenerationTask.releaseClaim:
GenerationChunkHolder.removeTask on the centre, then
ChunkMap.releaseGeneration on all 529. Every holder whose count reaches zero
completes its save-sync future, and the square is free to be saved or dropped
as its own tickets dictate — which the outer rings, only ever raised to the
status their distance demanded, mostly are.
ChunkHolder.scheduleFullChunkPromotion was called back in
ChunkHolder.updateFutures, long before any of this; what happens now is its
confirmation landing on the Server thread and ChunkMap.onFullChunkStatusChange tells
the entity manager. ChunkLoadCounter watches this from outside — at start-up
it holds the holders the restored saved tickets raise to FULL, and for a
joining player those its spawn ticket raises, drops each as it arrives and
feeds the load progress, and MinecraftServer.prepareLevels loops until none
is left.
The whole walk, once
Everything above, spent once on one chunk that was not on disk. The lanes are the seven objects that hand it along, and the interesting thing about them is which thread each is standing on.
sequenceDiagram
participant DM as DistanceManager
participant CM as ChunkMap
participant CTD as ChunkTaskDispatcher
participant CGT as ChunkGenerationTask
participant Worker as Worker
participant TLE as ThreadedLevel<br/>LightEngine
participant SL as ServerLevel
Note over DM,SL: the Server thread, inside ServerChunkCache.runDistanceManagerUpdates
DM->>CM: the holder reaches level 33 — updateHighestAllowedStatus, then updateFutures
CM->>CGT: create — one task, and a claim on all 529 holders
CM->>CTD: submit runUntilWait at the holder's queue level
Note over CTD,CGT: thread hop — the worldgen ConsecutiveExecutor, one task at a time per dimension
CTD->>CGT: runUntilWait, once the executor reaches this chunk
CGT->>CM: layer EMPTY at radius 1 — applyStep becomes scheduleChunkLoad
CM->>Worker: the pool's upgradeChunk and parseChunk, after the region read on the IO lane
Worker->>CM: back on the Server thread, no file, so createEmptyChunk
Note over CTD,CGT: the task yields on the first unfinished future, and is resubmitted
CGT->>CGT: nothing on disk, so EMPTY again, now to radius 11
CGT->>CM: STRUCTURE_STARTS to 11, then STRUCTURE_REFERENCES to 3
CM->>SL: onStructureStartsAvailable, posted to the Server thread
CGT->>Worker: BIOMES to 3 and TERRAIN to 2, forked to the pool
CGT->>CM: FEATURES to 1, inline
CGT->>TLE: INITIALIZE_LIGHT at 1, then LIGHT at 0, on the light executor
CGT->>CM: SPAWN at 0, inline
CGT->>SL: FULL, on the main-thread executor
SL-->>CGT: the LevelChunk, built, wrapped, loaded and registered
CGT->>CM: releaseGeneration on all 529, and the task is removed
One chunk from a level change to a live LevelChunk, handed between the
worldgen executor the task runs on, the IO lane for the region read, the
worker pool for its upgrade and parse stages and the two forks, the light
executor, and the Server thread for the read’s result, the posted structure
starts and ChunkStatus.FULL. The self-message in the middle is the
load-or-generate decision: below it, the sweep to radius 11 and the structure,
biome, terrain, feature and spawn generation are what a chunk already on disk
would skip, and the structure-start replay, the light steps and FULL are what
it would still run.
Questions players ask
Why does adding cores not speed up world generation? It speeds up only
the part that forks. A dimension’s inline steps run on one ConsecutiveExecutor,
one task at a time, and the dispatcher in front of it releases one chunk’s work
at a time; what it forks onto the worker pool — the biome fill and the terrain
job, the noise, the surface and the carvers — runs many chunks at once, and a
wider pool runs more of it. The structure starts, the features and
the spawns stay single file, and there is no thread-count setting for generation.
Why does a chunk I have visited before still take work to load? It walks all ten steps. Five of them pass through and cost nothing, but the disk read, the structure-start replay, both light steps and the FULL assembly are real work, and its LIGHT step needs the 3×3 neighbours read first.
Why does a chunk sometimes hang on the edge of the view forever? Its
ticket level puts the ceiling below FULL.
GenerationChunkHolder.isStatusDisallowed refuses anything higher, so the
chunk sits at whatever its level allows — STRUCTURE_STARTS at the outer
edge, INITIALIZE_LIGHT just outside the ring that reaches FULL — correct
and unfinished, for as long as the level says so.
For a 1.21-era reader. The noise, surface and carvers statuses are gone: terrain does their work, as one step. The pool jobs init_biomes and wgen_fill_noise are now createBiomes and buildTerrain.
Where to look
The two lists that decide everything: ChunkPyramid.GENERATION_PYRAMID ·
ChunkPyramid.LOADING_PYRAMID · ChunkStep.getAccumulatedRadiusOf. Then one
task’s life, in order: ChunkGenerationTask.create ·
ChunkGenerationTask.runUntilWait ·
ChunkGenerationTask.canLoadWithoutGeneration ·
ChunkGenerationTask.scheduleChunkInLayer. Where a step runs:
ChunkMap.applyStep · GenerationChunkHolder.acquireStatusBump ·
ChunkStatusTasks.full. And what polices a running step:
WorldGenRegion.getChunk · WorldGenRegion.ensureCanWrite.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Lighting
Verified against Minecraft 26.3 · Part IV · A torch is placed on a cave wall: the write queues a task, a worker floods the change, and the sections it touched are published as a copy and mailed to the client.
A player right-clicks a torch onto stone. The block goes into its
LevelChunkSection immediately, the heightmaps move, and then
LevelChunk.setBlockState reaches the light engine, for this torch, only to
call LevelLightEngine.checkBlock, which on the server computes nothing at
all.
It wraps the call in a runnable and puts it on a queue the level tick never
drains itself. There is no light thread and no light phase of
the tick: the flood that turns the torch’s 14 into a sphere of falling
numbers runs because the Server thread went looking for something to do,
usually because the tick finished early. The kick is
ThreadedLevelLightEngine.tryScheduleUpdate, and its usual caller is
ServerChunkCache.MainThreadExecutor.pollTask, which the idle poll
MinecraftServer.pollTaskInternal reaches while the server is ahead of its
schedule, blocked or sprinting, and a synchronous chunk wait reaches in the
middle of a tick. Ask the server engine to light something synchronously and
it refuses: ThreadedLevelLightEngine.runLightUpdates throws.
The cast
| class | what it decides | thread |
|---|---|---|
LevelLightEngine | the facade both sides hold — one LightEngine per layer, the read side, and the padding above and below the world | whoever calls it |
ThreadedLevelLightEngine | the server’s wrapper: every mutator becomes a queued task, and running updates on demand is an error | queued from Server, drained on the light executor |
BlockLightEngine · SkyLightEngine | the algorithm — what a changed block means, and how far the change travels | the light executor (Client: the Render thread) |
LayerLightSectionStorage | the double buffer: which sections hold data, which changed, and when to publish | the same |
DataLayer | 2048 bytes of nibbles for one section, or no bytes at all | whoever holds it |
ChunkSkyLightSources | the chunk’s 256-entry lowest sky source Y table — the one piece of light work that is not deferred | Server, inside the block write |
ServerChunkCache | the LightChunkGetter: which chunks the engine may read, and where the one callback lands | callback on the light executor, body on Server |
ChunkHolder | which sections each watching player is owed, as two BitSets | Server |
From a click to a lit wall
sequenceDiagram
participant LC as LevelChunk
participant TLE as ThreadedLevel<br/>LightEngine
participant SCC as ServerChunkCache
participant BLE as BlockLightEngine
participant LLSS as LayerLight<br/>SectionStorage
participant CH as ChunkHolder
participant CL as ClientLevel
Note over LC,CH: one server tick, inside LevelChunk.setBlockState
LC->>LC: the light properties differ, so the column's sky-light source is repaired
LC->>TLE: checkBlock, as a PRE_UPDATE task at the chunk's queue level
Note over LC,SCC: still the same tick, and nothing has been lit
SCC->>TLE: tryScheduleUpdate, because the poll found no chunk work
Note over TLE,LLSS: the light executor, off the Server thread
TLE->>BLE: the window's PRE tasks reach checkBlock, then runLightUpdates on this layer
BLE->>BLE: checkNode enqueues a pull-in decrease and an emission increase of 14
BLE->>LLSS: setStoredLevel, 14 then 13, 12 and down, as the increases propagate
LLSS->>LLSS: a fresh copy of the updating map is published
LLSS->>SCC: onLightUpdate once per affected section
Note over SCC,CH: back on the Server thread, whenever the posted task is polled
SCC->>CH: sectionLightChanged — it bails below INITIALIZE_LIGHT, and again with no ticking chunk
Note over SCC,CH: end of ServerChunkCache.tickChunks, normally the next tick
SCC->>CH: broadcastChanges, once the chunk tick is over
CH->>CL: Clientbound<br/>LightUpdatePacket,<br/>to a player at the<br/>edge of the view
Note over CL: the next frame, not the next tick
CL->>CL: pollLightUpdates, applyLightData, then runLightUpdates on the client's own engine
A torch placed, and the four boundaries the server’s light crosses before it reaches a player at the edge of the view: the placement and the lighting are in the same tick but not on the same thread, the packet waits for the end of the chunk tick, and the client applies it on a frame rather than a tick. Nothing on this page blocks the Server thread on a light result.
Most sections below walk one stretch of that diagram. Two classes are absent
because they carry no decision: ChunkTaskDispatcher, which holds the queued
task under the chunk’s priority and hands it to the executor, and
ClientPacketListener, which on arrival only wraps the packet in a closure
for later.
Two 4-bit fields, and the sections that may not exist
Light is LightLayer.SKY and LightLayer.BLOCK, computed by separate
machinery that shares only its shape. LevelLightEngine holds one
LightEngine per layer — LevelLightEngine.skyEngine is null in a dimension
whose DimensionType.hasSkyLight is false, and
LevelLightEngine.getLayerListener hands out
LayerLightEventListener.DummyLightLayerEventListener in its place. Readers
rarely see the layers apart: LevelLightEngine.getRawBrightness is the
maximum of block light and sky light minus a darkening term, which is what
LevelReader.getMaxLocalRawBrightness passes LevelReader.getSkyDarken into,
and BlockAndLightGetter.canSeeSky is just sky light at 15.
Storage is per layer and per section, one section taller than the world at
each end — LevelLightEngine.LIGHT_SECTION_PADDING is 1 and
LevelLightEngine.getLightSectionCount is the world’s sections plus two. A
section’s storage is a DataLayer: 16×16×16 nibbles in DataLayer.SIZE
bytes, indexed y then z then x, and allocated lazily. DataLayer.data
starts null and every read answers DataLayer.defaultValue until the first
write forces the array into existence, and DataLayer.fill throws it away
again. DataLayer.isEmpty therefore means homogeneous zero, not all
zeroes I checked, and it is the test both the chunk saver and the packet
builder use to charge nothing for a dark section.
Which sections have a DataLayer at all is decided by
LayerLightSectionStorage.sectionStates, one byte per section holding a
has-data bit and a five-bit count of how many of its 26 neighbours have data
(LayerLightSectionStorage.SectionState). A byte of zero means no storage,
so a section of pure air beside a built-up one is allocated and a section in
the middle of nothing is not. Above the sky column’s top section there is no
storage at all, and SkyLightSectionStorage.getLightValue answers 15 without
looking; its upward walk happens below the top, when a
section inside the column has no DataLayer and the search climbs for one.
The write that queues nothing but a task
LevelChunk.setBlockState asks two questions about light. If the section
flipped between all-air and not — the torch is the first block into an empty
section, or the last one out — LevelLightEngine.updateSectionStatus goes in.
Then, if LightEngine.hasDifferentLightProperties says the old and new states
differ in emission or in dampening, or if either of them uses a shape for
light occlusion, two things happen. The first is ChunkSkyLightSources.update
on the chunk’s own table, and it runs inline, on the Server thread, inside
the block write: the sky column’s lowest source Y for that one (x, z) is
repaired immediately, because everything downstream reads it. The second is
LevelLightEngine.checkBlock, and on the server that is where the work stops.
ProtoChunk.setBlockState does the same pair, but only once the chunk’s
status has reached ChunkStatus.INITIALIZE_LIGHT; before that a generator
writing blocks tells the light engine nothing.
ThreadedLevelLightEngine overrides every mutator the same way. The call
becomes a Runnable tagged ThreadedLevelLightEngine.TaskType.PRE_UPDATE or
ThreadedLevelLightEngine.TaskType.POST_UPDATE and goes through
ChunkMap.lightTaskDispatcher, at the chunk’s own queue level for the five
mutators that belong to one chunk —
LevelLightEngine.checkBlock, LightEventListener.propagateLightSources,
LevelLightEngine.setLightEnabled, ThreadedLevelLightEngine.initializeLight
and ThreadedLevelLightEngine.lightChunk — and at a flat top priority for
bookkeeping such as LevelLightEngine.updateSectionStatus and
LevelLightEngine.queueSectionData. When the dispatcher releases the task it
runs on the light executor, not on the Server thread, and all it does there
is append to ThreadedLevelLightEngine.lightTasks — a plain array list with
no synchronisation, safe precisely because only that executor appends to it.
What actually kicks it
The light executor is a ConsecutiveExecutor named light, built in
ChunkMap’s constructor over the shared background pool. It has no thread of
its own: it takes one task from its queue, runs it on a borrowed pool thread,
and re-registers itself. Two callers ever schedule a batch on it.
ServerChunkCache.MainThreadExecutor.pollTask runs the distance-graph
updates first and calls ThreadedLevelLightEngine.tryScheduleUpdate only if
they had nothing to do — so light propagates in the Server thread’s spare
moments, after the ticket system is quiescent
(when the graphs run). Whichever
caller gets there, ThreadedLevelLightEngine.scheduled, an AtomicBoolean,
keeps exactly one batch in flight. The other caller is
ChunkMap.scheduleUnload, kicking the engine right after it has queued the
nulling of an unloading chunk’s data.
If nobody kicks, the queue is still not unbounded:
ThreadedLevelLightEngine.addTask runs a batch inline as soon as
ThreadedLevelLightEngine.lightTasks has reached a thousand, the number
ThreadedLevelLightEngine.DEFAULT_BATCH_SIZE names — still on the light
executor, still never on the Server thread.
One batch, and what it publishes
The light executor’s stretch of that trace is one batch, and a batch is defined by the two maps it sits between rather than by the flooding it does.
flowchart TD
PRE["ThreadedLevelLightEngine.runUpdate takes a window of up to 1,000 tasks"]
C["LightEngine.checkNode, on every queued position"]
D["LightEngine.propagateDecreases, to an empty queue"]
I["LightEngine.propagateIncreases, to an empty queue"]
UP[("LayerLightSectionStorage.updatingSectionData: the scratch map, cloned per section")]
S["LayerLightSectionStorage.markNewInconsistencies,<br/>then LayerLightSectionStorage.swapSectionMap"]
VIS[("LayerLightSectionStorage.visibleSectionData: volatile, what every reader sees")]
POST["the window's POST_UPDATE tasks run, and the window is dropped"]
PRE --> C
C --> D
D --> I
C -- "writes" --> UP
D -- "writes" --> UP
I -- "writes" --> UP
I --> S
UP -- "copied whole" --> S
S --> VIS
S --> POST
One batch, drawn for one of the two layers it runs in turn between the same PRE and POST tasks, and the two maps that layer is written between: the three stages down the left all write the same scratch map, and none of them writes the one another thread reads. That is why the picture has two cylinders and not one — and why a reader of the visible map gets that layer before the batch or after it, never a half-propagated flood.
The two maps are why no reader of light ever waits on the light engine.
LayerLightSectionStorage.updatingSectionData is the engine’s scratch copy
and no other thread reads it; LayerLightSectionStorage.visibleSectionData is
volatile and is what every reader sees, and what the saver and the packet
builder see wherever no newer layer is still queued.
LayerLightSectionStorage.setStoredLevel clones a section’s DataLayer
through DataLayerStorageMap.copyDataLayer the first time the batch touches
that section, recording it in LayerLightSectionStorage.changedSections, and
LayerLightSectionStorage.swapSectionMap copies the whole updating map into
a new visible map at the end. A reader of the visible map on another thread
sees each layer before the batch or after it, never a half-propagated flood.
Inside a layer the order is fixed. Every position from
LightEngine.blockNodesToCheck goes through LightEngine.checkNode, which
zeroes the stored level where the light that is there must go and enqueues the
rest as work; then LightEngine.propagateDecreases runs the decrease
queue to empty, then LightEngine.propagateIncreases runs the increase queue
to empty. Decreases always finish first because a decrease discovers brighter
neighbours and enqueues them as increase back toward me refills — running
increases first would spread light that is about to be removed. Both queues
hold pairs of longs whose second member is a LightEngine.QueueEntry: four
bits of level, six of allowed directions, and the two flags
LightEngine.QueueEntry.FLAG_FROM_EMPTY_SHAPE and
LightEngine.QueueEntry.FLAG_INCREASE_FROM_EMISSION. The torch’s own entries
are LightEngine.PULL_LIGHT_IN_ENTRY — a level-1 decrease in all six
directions, meaning re-pull from my neighbours — and an emission increase of
14, a torch, Blocks.TORCH or Blocks.WALL_TORCH, being a
BlockBehaviour.Properties.lightLevel of 14.
BlockLightEngine.propagateIncrease then spreads level minus
LightEngine.getOpacity, which is
BlockBehaviour.BlockStateBase.getLightDampening — by default derived rather
than declared: 15 for a solid render, 0 for a state that
BlockBehaviour.BlockStateBase.propagatesSkylightDown, 1 otherwise, though
leaves and tinted glass declare their own — floored
at LightEngine.MIN_OPACITY. It stops where a neighbour is already as bright
and where LightEngine.shapeOccludes. A level of 1 is still written; what
it does not do is propagate, because only a new level above 1 is enqueued
again. That is why a torch at 14 reaches thirteen blocks and no further.
The batch tells the chunk holders which sections changed only through
LightChunkGetter.onLightUpdate, fired from the map swap. The engine has no
idea that ChunkHolder exists.
The sky column is a table, not a flood
Sky light does not use the heightmaps, and it does not usually propagate
downward one block at a time. Each chunk owns a ChunkSkyLightSources
(ChunkAccess.skyLightSources), a bit-packed 256-entry table of the lowest
Y at this (x, z) that still sees the sky, filled by
ChunkSkyLightSources.fillFrom and repaired per block by
ChunkSkyLightSources.update. What ends a column is
ChunkSkyLightSources.isEdgeOccluded: the lower block dampens light at all,
or the two faces occlude each other.
SkyLightEngine.checkNode reads that table and takes one of three paths. A
block at or above the column’s lowest source enqueues a remove-source
decrease and an add-source increase — the expensive case. A block below it
that held light has that light zeroed and decreased away. A block below it
that was already dark, which is what a torch under a solid ceiling is, gets a
pull-in that changes nothing: placing a torch in a cave does no sky work
worth measuring. Before any of the three, SkyLightEngine.updateSourcesInColumn
runs SkyLightEngine.removeSourcesBelow and SkyLightEngine.addSourcesAbove
so the stored 15s agree with the table again.
Two more pieces of the sky model exist because sections may have no storage.
SkyLightEngine.propagateFromEmptySections handles light crossing a section
edge sideways at the bottom row of a section: if the source column had no
data for a run of sections below it, light had been continuing downward
implicitly, so the same level is written straight down the destination column
through that run. And SkyLightSectionStorage.createDataLayer seeds a newly
needed section below existing data by repeating the bottom slice of the
section above it (SkyLightSectionStorage.repeatFirstLayer) rather than
starting dark.
Lit before you ever see it
Generation lights a chunk in two steps, and the first usually turns light
off. ChunkStatusTasks.initializeLight builds the sky table with
ChunkAccess.initializeLightSources, then calls
ThreadedLevelLightEngine.initializeLight, whose PRE task marks every
non-air section as having data and whose POST task sets the column’s enabled
flag to whatever ChunkStatusTasks.isLighted says — false for a freshly
generated chunk, whose persisted status has not reached ChunkStatus.LIGHT.
Enabling is the next step’s doing: ChunkStatusTasks.light calls
ThreadedLevelLightEngine.lightChunk, which for an unlit chunk runs
LevelLightEngine.propagateLightSources, and both
BlockLightEngine.propagateLightSources and
SkyLightEngine.propagateLightSources open by enabling the column before
seeding it — the block engine from LightChunk.findBlockLightSources, the sky
engine from its own table and its four neighbours’. Only then does a POST
task set ChunkAccess.setLightCorrect. A chunk read from disk with
isLightOn set skips the propagation entirely, because
ChunkStatusTasks.isLighted hands ThreadedLevelLightEngine.lightChunk a
flag that says it is lit.
Two consequences are worth naming. ServerChunkCache.getChunkForLighting
hands the engine chunks at ChunkStatus.FEATURES, one status below
ChunkStatus.INITIALIZE_LIGHT — the engine reads chunks the rest of the game
is not allowed to see yet. And nothing waits for light at the send itself: a
chunk reaches ChunkStatus.FULL only after its own ChunkStatus.LIGHT task
has run on the light executor, and ChunkPyramid.GENERATION_PYRAMID gives
ChunkStatus.LIGHT a requirement of ChunkStatus.INITIALIZE_LIGHT at radius
1, so a chunk cannot climb to ChunkStatus.FULL until its neighbours have
their sections marked too (the pyramid,
drawn), and it is sent only
from ChunkMap.prepareTickingChunk, which waits for all eight neighbours at
ChunkStatus.FULL, so each of them has run its own ChunkStatus.LIGHT task
as well. The one real send
dependency, ChunkMap.waitForLightBeforeSending →
ThreadedLevelLightEngine.waitForPendingTasks → ChunkHolder.addSendDependency,
has exactly one caller: EnderDragonFight, holding back the send of the
chunks around a new exit portal until its light is in.
And what unloading throws away
Unloading is the mirror: ChunkMap.scheduleUnload calls
ThreadedLevelLightEngine.updateChunkStatus, which disables the column,
drops the retain flag and nulls every section’s data
(a chunk nobody needs any
more). On load
SerializableChunkData.read
calls LevelLightEngine.retainData once and then
LevelLightEngine.queueSectionData for each saved DataLayer.
Off the server thread and onto the wire
ServerChunkCache.onLightUpdate is the whole of the hop back. It is called
on the light executor, and its body is a task posted to
ServerChunkCache.mainThreadProcessor — no ChunkHolder is touched
off-thread. When the Server thread later runs that task it finds the holder
in the visible map and calls ChunkHolder.sectionLightChanged, which has two
gates and does its one piece of bookkeeping between them. It gives up at once
on a chunk not present at ChunkStatus.INITIALIZE_LIGHT; otherwise it marks
the chunk unsaved (light is saved data); then it gives up again if there is no
ticking chunk to broadcast for. Only past both, for a section in range whose
bit is not already set, does it set one bit in
ChunkHolder.skyChangedLightSectionFilter or
ChunkHolder.blockChangedLightSectionFilter and answer yes, and the posted
task then puts the holder in ServerChunkCache.chunkHoldersToBroadcast. So a
chunk short of ChunkStatus.INITIALIZE_LIGHT is not even marked dirty by a
light change, and one that is loaded but not ticking is
marked dirty and never broadcast.
One packet, and only to the players who cannot work it out
The packet goes out at the end of ServerChunkCache.tickChunks — once a tick
even while the tick rate is frozen, and never at all in a debug world,
whose Level.isDebug guard wraps that whole method.
ServerChunkCache.broadcastChangedChunks calls ChunkHolder.broadcastChanges,
which builds one ClientboundLightUpdatePacket per chunk from the two
filters and sends it before any block-change packet — but not to everyone
watching. It asks ChunkHolder.PlayerProvider.getPlayers with borderOnly
true, so ChunkMap.isChunkOnTrackedBorder keeps only the players for whom
this chunk has an untracked neighbour. A player in the middle of their own
view distance is never sent light for a block change: they were sent the
block, they have every neighbouring chunk, and their own engine will reach
the same numbers. The packet exists for the players who cannot, because the
flood may be arriving from a chunk they do not have. Its
ClientboundLightUpdatePacketData carries four bitsets — a data mask and an
empty mask per layer — and the 2048 bytes of each non-empty changed section,
read through LayerLightEventListener.getDataLayerData, which answers the
queued layer if there is one and the visible map otherwise, never the
updating copy. A section whose DataLayer is empty costs one bit; one with
no DataLayer at all appears in neither mask. The same
ClientboundLightUpdatePacketData rides inside
ClientboundLevelChunkWithLightPacket with both filters null — every section
— when PlayerChunkSender.sendChunk first sends a chunk.
Up to fourteen sections can be dirtied by one torch, and not because a
write marks its neighbours. LayerLightSectionStorage.setStoredLevel marks
only the
sections within one block of the position written
(SectionPos.aroundAndAtBlockPos): one for an interior block, up to eight
for a block on a corner. The 14 comes from the flood, which writes as far as
thirteen blocks away in taxicab distance. Grow that octahedron by the
one-block halo and it can reach three sections deep on every axis, but never
fills the 3×3×3 box of 27, whose outer sections need diagonals the octahedron
does not have. Fourteen sections, across as
many as seven chunk columns — and each of the fourteen is one the client will
have to re-mesh. The real 3×3×3
marking, LayerLightSectionStorage.markSectionAndNeighborsAsAffected, fires
only when a section is first given a DataLayer.
The client lights per frame
The client runs the same LevelLightEngine unwrapped: ClientChunkCache.lightEngine
is a plain one, and its LightChunkGetter.onLightUpdate goes straight to
LevelExtractor.setSectionDirty. It runs from ClientLevel.update, which
Minecraft.renderFrame calls once a frame, not once a tick, so light
converges at your framerate. ClientPacketListener.handleLightUpdatePacket
applies nothing; it pushes a closure onto ClientLevel.lightUpdateQueue, and
ClientLevel.pollLightUpdates runs a tenth of them per frame, at least ten,
or, once a thousand are queued, all of them (what it
does simulate: the two
cadences
owns that budget). Each closure is ClientPacketListener.applyLightData:
ClientPacketListener.readSectionList turns every masked section into a
cloned or empty DataLayer through LevelLightEngine.queueSectionData and
dirties it with its neighbours, and then the chunk’s column is enabled with
LevelLightEngine.setLightEnabled. Only after the polled closures does
ClientLevel.update call LevelLightEngine.runLightUpdates, splicing the
queued layers in and swapping the map exactly as the server does.
Enabling a column is a separate thing from lighting it, and on the client that
distinction reaches all the way to the geometry: a section is not meshed at all
until LevelLightEngine.lightOnInColumn is true for each of its eight
surrounding columns, so a light flag decides whether a section may have a
mesh (the sweep that only looks at what you can
see).
Meanwhile the client had already lit this torch itself:
MultiPlayerGameMode.startPrediction runs the placement locally through the
same LevelChunk.setBlockState, and for the player who placed it, in the
middle of their own view, no light packet follows at all: their own engine’s
numbers are the ones they see.
Questions players ask
Why does the server’s light reach some players a tick after the torch
lands? Only a player at the edge of the view is sent light at all, and for
them the write only queues a task, the queue waits for
the Server thread’s spare moments, the callback is posted back to the Server
thread, and the packet is built at the end of ServerChunkCache.tickChunks —
four hand-offs, only the last of them on a schedule. Every watching client
lights the torch itself when the block arrives; the packet brings what flooded
in from chunks that player does not hold.
Why do the F3 light numbers not match what I see? They are three
numbers, and none of them is the one you are looking at. DebugEntryLight
prints a combined figure and then the two raw layers, and the combined figure
is LevelLightEngine.getRawBrightness called with the time-of-day darkening
argument set to zero — so even it is a noon answer. What renders is the
two layers looked up in the Lightmap texture, which is where the hour, the
dimension and the mob effects arrive (the lightmap).
For a 1.21-era reader. getLightBlock is now
BlockBehaviour.BlockStateBase.getLightDampening, and LightTexture isLightmap(Part XI).DynamicGraphMinFixedPoint,LeveledPriorityQueueandSpatialLongSetstill live inworld/level/lighting, but no light engine uses them any more:DynamicGraphMinFixedPointsurvives forChunkTracker(tickets) andSectionTracker(points of interest) and takesLeveledPriorityQueuewith it, andSpatialLongSethas no callers at all.
Where to look
Follow the torch: LevelChunk.setBlockState ·
LightEngine.hasDifferentLightProperties ·
ThreadedLevelLightEngine.tryScheduleUpdate ·
ThreadedLevelLightEngine.runUpdate · BlockLightEngine.checkNode ·
BlockLightEngine.propagateIncrease ·
LayerLightSectionStorage.swapSectionMap. The sky’s own machinery is a
separate read: ChunkSkyLightSources.update · SkyLightEngine.checkNode ·
SkyLightSectionStorage.getLightValue. Then the way out:
ServerChunkCache.onLightUpdate · ChunkHolder.broadcastChanges ·
ClientPacketListener.applyLightData · ClientLevel.pollLightUpdates. Where
generation turns light on and computes it:
ThreadedLevelLightEngine.initializeLight ·
ThreadedLevelLightEngine.lightChunk.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Chunk storage
Verified against Minecraft 26.3 · Part IV · A chunk nobody needs any more is dropped from the world and written to disk, and the server thread never waits for it.
You walk away from your base. Soon after, the chunk you were standing in is
no longer reachable from any ticket, its loading level climbs past
ChunkLevel.MAX_LEVEL, and a queued task takes a snapshot of it, hands
that to a worker to turn into NBT, and hands that to a lane that
compresses it and finds it somewhere to live in r.X.Z.mca. Nothing about
that is surprising. What is surprising is that the chunk was almost
certainly written several times before you left, and that none of those writes
was anybody’s idea. A chunk you keep changing is written by a
background sweep roughly every ten seconds — ChunkMap.saveChunksEagerly,
at most ChunkMap.CHUNK_SAVED_EAGERLY_PER_TICK (20) chunks a tick, only
while fewer than ChunkMap.MAX_ACTIVE_CHUNK_WRITES (128) writes are in
flight, each chunk no sooner than
ChunkMap.EAGER_CHUNK_SAVE_COOLDOWN_IN_MILLIS (10 000 ms) after its last —
and the autosave everyone thinks of as the save comes every five minutes of
the server’s scheduled time, at any steady /tick rate. Almost every write
of your world is one
nobody asked for.
The cast
| class | what it decides | thread |
|---|---|---|
ChunkMap | which chunks are dirty, at which of four moments each is written, and whether a half-generated chunk may overwrite a finished one — it is the region/ store, because it extends SimpleRegionStorage | Server |
SerializableChunkData | the chunk file as a record: what gets copied while the world is frozen and what gets encoded after | Server copies, a Worker-Main-n encodes |
IOWorker | one store’s single lane, and the write-behind map that lets a read answer from a write that has not landed | any IO-Worker-n, one task at a time |
RegionFileStorage | which r.X.Z.mca files are open — an LRU of RegionFileStorage.MAX_CACHE_SIZE (256) | the IO lane |
RegionFile | the sector allocator and the two header tables of one 32×32-chunk file, and the order the bytes land in | the IO lane |
EntityStorage | the entities/ store: what a chunk’s mobs cost to write, and that they are rebuilt on the Server thread | Server builds and parses, IO lane writes |
SectionStorage | the poi/ store under PoiManager: which sections are dirty, and the one load that blocks | Server |
MinecraftServer | when the next autosave falls, and whether every region file is opened with DSYNC | Server |
Copy on the server, encode on a worker, write on the IO lane
flowchart LR
S["the Server thread:<br/>SerializableChunkData.copyOf"]:::server
W["a worker:<br/>SerializableChunkData.write"]:::worker
F["the IO lane, foreground:<br/>IOWorker.store"]:::disk
B["the IO lane, background:<br/>IOWorker.storePendingChunk"]:::disk
R["RegionFileStorage.write"]:::disk
D[("r.X.Z.mca")]:::disk
S --> W
W --> F
F --> B
B --> R
R --> D
One chunk saved, across the three lanes that touch it, coloured by which one:
the server’s share for a full chunk is the first box alone, and it ends at a snapshot, so
everything to the right of it happens after the tick that decided to save has
moved on. The two middle boxes are the same lane at two priorities — the
foreground task waits for the encode and parks the tag in
IOWorker.pendingWrites, and the background one runs only when nothing is
queued in front of it, which is why a write can sit there for a long time and
cost the Server thread nothing.
That figure is the page’s answer to why doesn’t saving lag the server. Apart
from flushing the chunk’s POI column and, for a chunk that is not full, the
guard’s read, the Server thread’s whole share of a
save is the middle of ChunkMap.save: SerializableChunkData.copyOf, which
copies every LevelChunkSection with
LevelChunkSection.copy and each non-empty block and sky DataLayer out of
LevelLightEngine.getLayerListener, clones every heightmap the chunk
holds, pulls block-entity NBT through
ChunkAccess.getBlockEntityNbtForSaving, packs the ticks through
ChunkAccess.getTicksForSerialization, and packs the structure starts.
Everything after that — the palette codecs, the deflate, the sector
arithmetic, the syscall — runs somewhere else, and ChunkMap.save returns
as soon as the copy is done.
Four folders, three of them the same shape
LevelStorageSource.LevelStorageAccess.getDimensionPath defers to
DimensionType.getStorageFolder, which puts every dimension — the
overworld included — under dimensions/<namespace>/<path>/ in the world
folder. Inside are region/, entities/, poi/ and data/. The first
three are region stores of the same shape — a folder of r.X.Z.mca files, a
RegionFileStorage, an IOWorker, and a RegionStorageInfo naming the
store (chunk, entities or poi) so that
MinecraftServer.reportChunkSaveFailure can say which one broke. Only
chunk belongs to ChunkMap itself; EntityStorage and SectionStorage
each hold a SimpleRegionStorage rather than being one. data/ is not a
region store at all, and it is the section below.
A LevelChunk’s entities are not in region/. The
SerializableChunkData.entities list is written only when the chunk’s
persisted status is a ChunkType.PROTOCHUNK — worldgen’s spawns, waiting
for the column to become full. A full chunk’s entities live in entities/,
one region entry per chunk, holding a Position and an Entities list. If
an old save still has entities inside a full chunk’s region/ entry,
ServerLevel.addLegacyChunkEntities adopts them on load.
The other store under data/
SavedDataStorage is the fourth folder and the one that is not a region
store: no sectors, no LRU of open files, one gzipped
data/<namespace>/<path>.dat per thing that has state. It keeps the
Server thread off the disk as a chunk does, and for the same reason.
SavedDataStorage.scheduleSave encodes every dirty entry on the
caller’s thread — the Server thread, inside the save it was asked for —
and hands the finished tags to Util.ioPool, at most
Util.maxAllowedExecutorThreads writes at a time, chaining each onto
SavedDataStorage.pendingWriteFuture so that two saves of one file cannot
race. SavedDataStorage.saveAndJoin is the only place anything waits, and
only a flush save, shutdown or the world upgrader reaches it. Encode while the
world is still, write while it moves: half the bargain the chunk path makes,
over a much smaller object. Which file holds what belongs to level data and
rules.
The four moments a chunk is written
| the moment | what runs it | which chunks | what holds it back |
|---|---|---|---|
| an unload | the task ChunkMap.scheduleUnload queued, drained by ChunkMap.processUnloads | the one chunk being dropped, at whatever status it reached | the tick’s budget, except for the oldest tasks past 2,000 in ChunkMap.unloadQueue, which drain regardless; no cooldown |
| the eager sweep | ChunkMap.saveChunksEagerly, the last statement of that same ChunkMap.processUnloads | everything in ChunkMap.chunksToEagerlySave | 20 a tick, fewer than 128 writes outstanding, the tick’s time budget, and ten seconds per chunk |
| an autosave | MinecraftServer.autoSave → ServerLevel.save → ServerChunkCache.save → ChunkMap.saveAllChunks, without flush | every holder in ChunkMap.visibleChunkMap | only the per-chunk gates: ChunkMap.saveAllChunks clears ChunkMap.nextChunkSaveTime, but ChunkMap.saveChunkIfNeeded still wants an accessible, ready, unsaved LevelChunk (an ImposterProtoChunk passes its type test but is never unsaved) |
| a flush save | /save-all flush, /stop, ServerChunkCache.close | every accessible holder, over and over until a pass saves none | it blocks the Server thread instead |
The dirty set behind the second row is narrower than it looks.
ChunkMap.setChunkUnsaved is installed as WorldGenContext.unsavedListener
and handed to a chunk by ChunkStatusTasks at the moment it becomes full,
and LevelChunk.markUnsaved fires that listener only on the false→true
edge. So ChunkMap.chunksToEagerlySave holds full chunks that have
changed since their last write, each added once, and a chunk still being
generated is never in it.
Turning saving off is not as total as it sounds. ChunkMap.tick ticks
PoiManager first and unconditionally, and only then asks
ServerLevel.noSave whether to run ChunkMap.processUnloads — so a no-save
world still writes village data through SectionStorage.tick, and stops
letting go of chunks until something forces the issue. The only other drain
of ChunkMap.unloadQueue and ChunkMap.toDrop is inside
ChunkMap.saveAllChunks with flush, which runs ChunkMap.processUnloads on
an always-true budget — so /save-all flush unloads them, since
MinecraftServer.saveAllChunks suppresses ServerLevel.noSave when force
is set. Shutdown needs neither: it clears ServerLevel.noSave on every level,
which reopens the tick’s drain, and ticks until ChunkMap.hasWork is false
before its flush save. An explicit save is a
different question again:
MinecraftServer.saveAllChunks passes ServerLevel.noSave on to
ServerLevel.save only when its force flag is clear, and /save-all
sets that flag while MinecraftServer.autoSave does not.
The guard against writing a half-made chunk over a finished one, and where it leaks
The cast row promises this one, so here it is: ChunkMap.save refuses to
write a non-full chunk over a full one on disk, and the refusal is best
effort in two ways. ChunkMap.isExistingChunkFull returns false — meaning
go ahead — whenever the read throws or comes back empty, so an IO error
licenses exactly the clobber the guard exists to prevent. And
ChunkAccess.tryMarkSaved clears the unsaved flag before the guards run,
so a chunk the guard turns away has already been marked clean and will not be
offered again.
Two shapes never reach the write at all. A proto chunk still at
ChunkStatus.EMPTY with no valid structure start is dropped by the same
block, and an ImposterProtoChunk refuses outright:
ImposterProtoChunk.tryMarkSaved and ImposterProtoChunk.canBeSerialized are
flat falses (so is ImposterProtoChunk.isUnsaved), where
ImposterProtoChunk.markUnsaved, ImposterProtoChunk.isLightCorrect and
ImposterProtoChunk.setLightCorrect defer to the chunk underneath (chunk
anatomy). The imposter’s
reason is that the LevelChunk under it is what the saver will be handed
instead.
A chunk nobody needs any more
The save above is the middle of a longer story, which ordinarily runs inside one tick and stretches over several only when the budget runs out or a save dependency is pending. This is the whole of it, from the level change that drops the chunk to the write that finally records it.
sequenceDiagram
participant DM as DistanceManager
participant CM as ChunkMap
participant CH as ChunkHolder
participant SCD as SerializableChunkData
participant IOW as IOWorker
participant SL as ServerLevel
participant PESM as PersistentEntity<br/>SectionManager
DM->>CM: the level climbs past ChunkLevel.MAX_LEVEL, so the key joins ChunkMap.toDrop
Note over CM,CH: the unload phase of the same ServerChunkCache.tick
CM->>CM: processUnloads moves<br/>the holder to<br/>ChunkMap.<br/>pendingUnloads
CM->>CH: getSaveSyncFuture, and the unload task is hung off it
CH-->>CM: the future is complete, or completes later, and the task joins unloadQueue
Note over CM,CH: the same drain while the budget says yes, or a later one, or among the oldest past 2,000
CM->>CH: getSaveSyncFuture again — the same one, or the task rearms and stops
CM->>CM: this exact holder<br/>leaves ChunkMap.<br/>pendingUnloads, or<br/>a ticket took it back
CM->>CM: setLoaded false, then save — PoiManager.flush and the proto-over-full guard
CM->>SCD: copyOf takes the snapshot, a worker turns it into a CompoundTag
CM->>IOW: store, handed the encode future, on the chunk lane
CM->>SL: unload clears the block entities and the tick containers
Note over SL,PESM: in a walk-away, the entities left rings earlier, when the chunk fell below FULL
IOW-->>CM: the IOWorker.PendingStore future completes, one fewer active write
A chunk nobody needs, from the level that stopped needing it to the write that records it, ordinarily inside one tick: the two note bars mark where the budget or a pending save can push the rest to a later one, and the two dotted arrows are completions the Server thread does not wait for. The entities are not in it — in a walk-away they left by a different road, earlier, when the chunk fell below full.
Three things there are load-bearing. The first is that nothing happens until
ChunkHolder.saveSync is done: every promotion future is chained into it by
ChunkHolder.addSaveDependency, and so is
GenerationChunkHolder.generationSaveSyncFuture for as long as a generation
step holds a reference, so a chunk mid-promotion or mid-generation cannot be
saved or unloaded at all.
The second is the guard. ChunkMap.pendingUnloads is removed by identity:
if a ticket re-adopted the position while the task waited,
ChunkMap.updateChunkScheduling has already pulled the holder back out of
that map, the removal fails, and the task quietly does nothing — nothing is
lost and nothing is written twice. And if the sync future changed while
waiting, the task rearms itself on the new one rather than proceeding.
The third is that entities go by a different road, and earlier.
PersistentEntitySectionManager.updateChunkStatus saw the chunk fall below
full, rings before it was dropped, and queued the position in
PersistentEntitySectionManager.chunksToUnload.
If the chunk’s entity file is still being read,
PersistentEntitySectionManager.storeChunkSections returns false and the
whole thing is retried next tick, so a half-loaded set never clobbers the
file. Otherwise each entity EntityAccess.shouldBeSaved accepts is serialised
with Entity.save on the Server thread, its passengers inside it, the tag
goes to the entities lane, and those entities and their passengers are removed with Entity.RemovalReason.UNLOADED_TO_CHUNK. The three types besides Player built never to be saved — a leash knot, a lightning bolt, a fishing bobber — pass the filter, write nothing in Entity.save, and are removed with the rest. The filter runs before
the removal, not after it, so what it turns away on its own account — a
Player, a vehicle carrying exactly one player — is neither written nor
removed. The last of those is not an oddity: ServerPlayer writes its own
root vehicle into the player file under RootVehicle under exactly that
condition, so a boat with one rider travels in the player’s file rather than
the chunk’s.
Two other things leave with the chunk, both after the snapshot is taken:
ServerLevel.unload clears its block entities and unregisters its tick
containers, and ThreadedLevelLightEngine.updateChunkStatus queues the
light engine to forget its layers (lit before you ever see
it).
Why the server thread never waits
IOWorker is not a thread. It holds a PriorityConsecutiveExecutor over
Util.ioPool — a cached pool whose threads are named IO-Worker-n — and
its guarantee is that one task at a time runs for that store, not that the
same thread runs them. Its three priorities are strictly ordered:
IOWorker.Priority.FOREGROUND for IOWorker.store and
IOWorker.loadAsync, IOWorker.Priority.BACKGROUND for
IOWorker.storePendingChunk — the task that touches the disk — and
IOWorker.Priority.SHUTDOWN last. The lowest priority has exactly one user
in the whole game: the barrier IOWorker.waitForShutdown parks behind
everything else when the store closes. A flush is not one of them —
IOWorker.synchronize submits it at foreground priority like a store — but
it still lands behind the writes, because before it flushes it waits on
every IOWorker.PendingStore future, and those complete only when the
background tasks have run.
IOWorker.pendingWrites is what that buys. It is a sequenced map from
ChunkPos to IOWorker.PendingStore, and a second store for a position
already in it overwrites that entry’s data in place without moving it, so
N saves of one chunk before the lane drains become one disk write and
one shared future. IOWorker.loadAsync looks in the same map first and
returns a copy of the pending tag, so a chunk re-loaded while its write is
still pending never touches the region file — read-your-writes by lane order
rather than by any lock. IOWorker.STORE_EMPTY is the null supplier that
means delete, and IOWorker.scanChunk is the streaming ChunkScanAccess
that StructureCheck uses to peek into chunks nobody has loaded.
The three places that do wait
Three places besides a synchronous chunk load and a flush save make the Server
thread wait on a disk. ChunkMap.isExistingChunkFull,
the guard that stops a ProtoChunk overwriting a finished chunk, answers
from ChunkMap.chunkTypeCache when it can but joins the read future inline
on a cold entry — the IO lane, then a datafix pass on the worker pool. And
SectionStorage.getOrLoad joins too, for a POI section that
SectionStorage.prefetch has not fetched. The third is not a chunk-storage
method at all: StructureCheck.tryLoadFromStorage joins IOWorker.scanChunk
to peek at a chunk it will not load, which is what an eye of ender, a
dolphin, an explorer map and /locate all end up doing on the Server thread.
Only the first is on the save path, and only for a chunk that is not full,
which is why a full chunk’s save costs a copy.
Inside a region file
The last box of the first figure is a file format, and it is where the ordering that makes a half-finished save survivable lives.
flowchart TD
A["IOWorker.storePendingChunk pops the oldest entry of IOWorker.pendingWrites"] --> B["RegionFileStorage.getRegionFile, an LRU of 256 regions"]
B --> C["RegionFile.getChunkDataOutputStream wraps a RegionFile.ChunkBuffer in the compressor, NbtIo writes in"]
C --> D["closing the buffer back-patches the length and calls RegionFile.write"]
D --> E{"how many sectors"}
E -- "under 256 sectors" --> F1["RegionBitmap.allocate takes the first free run"]
F1 --> F2["the compressed chunk goes to those new sectors — the content, first"]
F2 --> F3["offsets and timestamps updated, then RegionFile.writeHeader"]
F3 --> F4["any stale sidecar for this chunk is deleted"]
F4 --> Z["and only now are the old sectors freed"]
E -- "256 sectors or more" --> G1["one sector is allocated for a stub"]
G1 --> G2["a five-byte EXTERNAL_STREAM_FLAG stub, and the payload to a temp file"]
G2 --> G3["offsets and timestamps updated, then RegionFile.writeHeader"]
G3 --> G4["the temp file is moved onto c.X.Z.mcc — the content, last"]
G4 --> Z
Two ways to write one chunk, putting the content and the pointer to it in opposite orders: on the left the bytes are on disk before the header that names them, and on the right the header is written first, pointing at a stub, and the real payload only lands when the temp file is moved. Both arms meet at the last box, which is the rule that makes either safe: the old sectors are not freed until the new ones can be found.
A RegionFile is one r.X.Z.mca: two header sectors
(RegionFile.SECTOR_BYTES is 4096) holding a 1024-entry offset table,
RegionFile.offsets, packed as sector number ≪ 8 with the sector count in
the low byte, and a 1024-entry RegionFile.timestamps. Free space is a
RegionBitmap, with the header’s two sectors forced used at construction
and RegionBitmap.allocate handing out the first run big enough. Nothing
ever reads the timestamp table back: RegionFile.write stamps each entry
with epoch seconds from RegionFile.getTimestamp, and the only clock that
spaces one chunk’s writes is the monotonic Util.getMillis behind
ChunkMap.nextChunkSaveTime, while the autosave counts server ticks. None of
them is game time, which the chunk carries only as data. Each
stored chunk starts with RegionFile.CHUNK_HEADER_SIZE (5) bytes — a length
and a compression id — and both RegionFile.write and
RegionFile.getChunkDataInputStream are synchronised on the RegionFile
itself rather than on the channel, though in practice only one lane ever
drives a given folder.
Read the two branches of the figure against each other and the page’s best
fact falls out. For an ordinary chunk the new bytes are on disk before
the header points at them, and the old bytes are released after — so a
crash at any point leaves either the old chunk or the new one, and a chunk
never overwrites itself in place. For an oversized chunk the ordering is
reversed. Anything needing RegionFile.EXTERNAL_CHUNK_THRESHOLD (256)
sectors or more cannot be described by an eight-bit count field at all, so
it goes to a .mcc sidecar and the region file keeps only a stub carrying
RegionFile.EXTERNAL_STREAM_FLAG; and the sidecar is moved into place
after RegionFile.writeHeader has already committed the pointer to it,
destroying the previous copy at a fixed path. The in-file case is
content-then-pointer. The sidecar case is pointer-then-content. Either way
the ordering only buys anything if the writes reach the platter in that
order, which is what DSYNC is for:
MinecraftServer.forceSynchronousWrites returns true as the base default,
and two of the three servers override it — DedicatedServer from
DedicatedServerProperties.syncChunkWrites (sync-chunk-writes, default
true) and IntegratedServer from Options.syncWrites, whose default is
true only on Windows. GameTestServer keeps the base answer.
The compression byte is per chunk, not per file.
RegionFileVersion.selected — set once by RegionFileVersion.configure
from DedicatedServerProperties.regionFileComression (Mojang’s spelling;
the property is region-file-compression) — decides only what new writes
use, choosing between RegionFileVersion.VERSION_DEFLATE (the
RegionFileVersion.DEFAULT), RegionFileVersion.VERSION_NONE and
RegionFileVersion.VERSION_LZ4. Reads honour whatever byte each chunk
carries, including RegionFileVersion.VERSION_GZIP, which has no option
name and so can be read but never chosen, and
RegionFileVersion.VERSION_CUSTOM, which exists so that
RegionFile.createChunkInputStream can recognise it and refuse.
The way back in
Loading is the same road driven backwards, in four stages across three lanes.
ChunkMap.scheduleChunkLoad starts with IOWorker.loadAsync on the IO
lane; ChunkMap.readChunk then hops to Util.backgroundExecutor under the
name upgradeChunk for ChunkMap.upgradeChunkTag, which is
where datafixing happens; SerializableChunkData.parse runs on the same
pool under parseChunk, so those two stages share a lane; and
SerializableChunkData.read runs on the Server thread, where the sections are
installed, the saved light is queued into the
light engine, and PoiManager.checkConsistencyWithBlocks re-derives the
points of interest of any section whose stored record is missing or not marked valid.
Running beside all of it,
SectionStorage.prefetch pulls the POI file in, and the two are joined
before the server-thread step — which is exactly why that step’s
SectionStorage.getOrLoad calls do not block. From there the generation
pipeline
takes over — including what happens when the bytes will not parse.
Entities come back the same shape but land differently: EntityStorage
schedules both the datafix and EntityType.loadEntitiesRecursive on
EntityStorage.entityDeserializerQueue, a ConsecutiveExecutor over the
server main-thread executor, so only the NBT read is off-thread.
Doing all of it at once, with no server running
Optimize World in the world-select screen is the same read and the same
write with the game in between removed. WorldUpgrader starts a single daemon
thread, World Upgrader #n, and hands each of the three stores to a
RegionStorageUpgrader, which walks every r.X.Z.mca file in the folder,
datafixes each chunk tag and writes back the ones that changed — or every one,
into fresh region files, which is what compacts a save whose sectors have
fragmented, though only the dedicated server’s –recreateRegionFiles asks
for that and the button never does.
UpgradeProgress is the counter the screen reads. Nothing here loads a chunk,
generates one, or consults a status: the world is a folder of tags, and the
button’s whole promise is that every tag is at the current data version before
a server ever opens the save.
Questions players ask
Does the game stall when it saves? The chunk stores wait for the disk only
on a flush, though an autosave still copies every unsaved chunk, serialises
the loaded entities, and writes level.dat and every player’s file itself, on
the Server thread, in the tick it falls.
ChunkMap.saveAllChunks with flush loops over the accessible holders,
blocking the main-thread executor on each ChunkHolder.isReadyForSaving
until a whole pass saves nothing, then flushes POIs with
SectionStorage.flushAll, runs ChunkMap.processUnloads with an
always-true budget, and finally joins IOWorker.synchronize with flush.
That, with the entity store’s and the saved data’s own joins in the same save,
is where waiting for the disk is the point rather than an accident, and it is
what /save-all flush and /stop do.
Does lowering the tick rate push out my autosave? Only the one already
counting down, and never past five minutes. The interval is counted in ticks:
MinecraftServer.computeNextAutosaveInterval is the tick rate times 300 — or,
while the server is sprinting, 300 times the rate its recent tick times imply
— floored at MinecraftServer.MIMINUM_AUTOSAVE_TICKS (100, and the typo is
Mojang’s), and the very first is MinecraftServer.AUTOSAVE_INTERVAL (6000
ticks), so each autosave is five minutes of the server’s scheduled time at any
rate. MinecraftServer.onTickRateChanged recomputes it on every /tick rate
but assigns it only when it is smaller than the ticks left: a slower rate
stretches those ticks, up to five minutes from the change, and a faster one
can only bring the save forward. The server
tick has the rest of
that
loop.
Why is my entities/ folder full of files with nothing in them? Because
nothing deletes a region file once its entries are cleared — and emptying a
chunk costs one write. EntityStorage.storeEntities
with an empty set only writes when EntityStorage.emptyChunks did not
already contain the position, and that write is IOWorker.STORE_EMPTY,
which zeroes the region entry and deletes any sidecar. The first time a
chunk goes empty costs a write. Every later save of it costs nothing.
Where to look
ChunkMap.tick · ChunkMap.processUnloads · ChunkMap.scheduleUnload ·
ChunkMap.save · ChunkMap.saveChunksEagerly · ChunkMap.saveChunkIfNeeded ·
ChunkMap.saveAllChunks · SerializableChunkData.copyOf ·
SerializableChunkData.write · IOWorker.store ·
IOWorker.storePendingChunk · IOWorker.loadAsync ·
RegionFileStorage.write · RegionFile.write · RegionBitmap.allocate ·
PersistentEntitySectionManager.storeChunkSections ·
EntityStorage.storeEntities · SectionStorage.writeChunk ·
SectionStorage.prefetch · ChunkMap.scheduleChunkLoad ·
SerializableChunkData.read · MinecraftServer.computeNextAutosaveInterval ·
DimensionType.getStorageFolder
Next door: tickets and loading raises the level,
chunk anatomy owns what LevelChunkSection.copy copies,
lighting owns the layers the unload throws away, points of
interest owns the poi/ store, the server
tick owns
the budget every method here is handed,
how a server dies is the save that does
not happen, and entity
lifecycle is
what
Entity.RemovalReason.UNLOADED_TO_CHUNK means to a mob.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Scheduled ticks
Verified against Minecraft 26.3 · Part IV · A repeater’s input goes high, and the two ticks before its output follows are one entry in a queue.
A repeater set to its shortest delay is not counting anything. When the wire
behind it changes, DiodeBlock.neighborChanged, which RepeaterBlock
inherits, runs on the Server thread,
notices that DiodeBlock.POWERED no longer matches the input, and books an
appointment — a ScheduledTick naming this Block at this BlockPos, due
two game ticks from now, at TickPriority.HIGH — and then forgets. Two ticks
later a scheduler the repeater has never heard of hands the position back to
ServerLevel.tickBlock, which checks that a repeater is still there and
calls the block again. Nearly everything the world does later is one of
these: a fluid flowing, a pressure plate releasing, a dispenser firing, a
redstone torch burning out. They all share two queues per chunk
and one rule that surprises everybody: the queue dedups on type and
position alone, so a second tick for the same block — even a much sooner
one — is silently dropped.
“Rescheduling moves the tick” is folklore. ScheduledTick.UNIQUE_TICK_HASH
hashes the position and compares the type by object identity — the trigger
tick, the priority and the sub-order are no part of it — and
LevelChunkTicks.schedule queues a tick only if its dedup set,
LevelChunkTicks.ticksPerPosition, did not already hold that pair. The
booking that loses is not queued, not merged and not logged, which is why so
many blocks ask before they book: DiodeBlock, ComparatorBlock and
RedstoneTorchBlock consult LevelTickAccess.willTickThisTick, and seven
blocks — ObserverBlock, TargetBlock, LightningRodBlock, TripWireBlock,
SculkSensorBlock, DriedGhastBlock and SpeleothemBlock — consult
TickAccess.hasScheduledTick.
The cast
| class | what it decides | thread |
|---|---|---|
ScheduledTick | the appointment itself — type, position, trigger tick, TickPriority, sub-order — and the comparisons the system sorts and dedups by | a record, no thread |
LevelChunkTicks | one chunk’s queue and its dedup set: whether a booking is new, and which of its ticks is next | Server |
LevelTicks | the per-level scheduler: which chunks are due, which ticks run this level tick, and where the budget falls | Server |
ScheduledTickAccess | the write side every block sees, so no block knows which container it is booking into | any — the worldgen executor books through it |
ServerLevel | when the drain runs, the per-chunk gate it runs under, and the type re-check that makes a tick cancellable | Server |
SavedTick | the disk form: a relative delay in place of an absolute time | Server, written by the IO worker |
ProtoChunkTicks | a generating chunk’s bookings, all at delay zero | the worldgen executor |
BlackholeTickAccess | accept every booking and run nothing — the client’s answer to the whole system, and also an ImposterProtoChunk’s | Render, and the worldgen executor |
Those containers implement one small interface stack — TickAccess
(schedule, ask, count), TickContainerAccess per chunk, LevelTickAccess per
level, which also answers LevelTickAccess.willTickThisTick, and
SerializableTickContainer for one that can SerializableTickContainer.pack
itself for disk — which is why LiquidBlock and RepeaterBlock run unchanged
during generation, on a client that will never tick them, and on a server that
will.
The pipeline, end to end
The pipeline has a seam in it, booking and the drain, so it is two figures. This is booking into a live level; the worldgen executor books into a generating chunk’s own list instead (below).
flowchart TD
B["a block books: ScheduledTickAccess.scheduleTick"]
C["LevelAccessor.createTick — game time plus delay, a TickPriority, a sub-order"]
S{"LevelTicks.schedule looks for the chunk's container"}
DROP["Util.logAndPauseIfInIde — logged and dropped, never deferred"]
D{"LevelChunkTicks.ticksPerPosition already holds this type and position"}
SKIP["dropped in silence, even when this one is sooner"]
Q[("one priority queue per chunk, ordered by ScheduledTick.DRAIN_ORDER")]
B --> C
C --> S
S -- "no container" --> DROP
S -- "found" --> D
D -- "yes" --> SKIP
D -- "no" --> Q
Everything that can happen to an appointment booked into a live level before it is a queued tick — and two of the three outcomes are that it is not one. The right-hand refusal is the page’s subject: the dedup slot is checked against the type and position alone, so the second booking is thrown away whether or not it is due sooner than the one already there.
The drain is the other half, it runs once per level tick per type, and it never leaves the Server thread.
flowchart TD
IDX[("LevelTicks.nextTickForContainer: one entry per container, the head's time")]
SC{"LevelTicks.sortContainersToTick asks LevelTicks.tickCheck"}
DR["LevelTicks.drainContainers, while the budget lasts"]
RUN["LevelTicks.runCollectedTicks, to ServerLevel.tickBlock or ServerLevel.tickFluid"]
CL["LevelTicks.cleanupAfterTick empties all four working collections"]
AGAIN["a block books again, from inside its own run"]
IDX --> SC
SC -- "the chunk is not ticking" --> IDX
SC -- "due, and ticking" --> DR
DR -- "overtaken, budget left" --> DR
DR -- "not due, or the budget spent" --> IDX
DR --> RUN
RUN --> CL
RUN --> AGAIN
The three phases of one drain, and the ways back to the index, none of them a failure: a container whose next tick is not yet due waits for it, and one whose chunk is not ticking or whose budget ran out is late, never lost. The bottom-right box is the loop this page’s repeater lives in — the run books the next appointment, which re-enters the figure above.
Everything below is one stage of those two figures.
Booking: a type, a position, a time and a tie-breaker
A block calls one of the ScheduledTickAccess.scheduleTick defaults with a
delay in ticks and, optionally, a TickPriority. The default asks the level
for LevelAccessor.createTick, which stamps the appointment with
LevelAccessor.getGameTime plus the delay, the priority (TickPriority.NORMAL if
none was given) and a fresh sub-order from LevelAccessor.nextSubTickCount,
then hands it to ScheduledTickAccess.getBlockTicks or
ScheduledTickAccess.getFluidTicks. Two type arguments, two parallel
worlds: every class here — LevelTicks, LevelChunkTicks, ScheduledTick —
declares one parameter, and it is filled with Block in one world and Fluid
in the other, so a block tick and a fluid tick never meet and ServerLevel
owns one LevelTicks of each.
The sub-order is the FIFO tie-breaker for two ticks at the same time and
priority, and it carries the one threading fact of this page.
Level.subTickCount is a plain counter incremented by
Level.nextSubTickCount, because a level’s scheduler is touched only from the
Server thread; WorldGenRegion.subTickCount is an atomic one, because
generation books ticks off the Server thread, on the worldgen executor. The drain is server-thread
only. Booking is not.
TickPriority runs TickPriority.EXTREMELY_HIGH (−3) through
TickPriority.NORMAL (0) to TickPriority.EXTREMELY_LOW (3), lower first,
and it is not a queue-jump across time: a TickPriority.LOW tick due now
still beats a TickPriority.EXTREMELY_HIGH tick due next tick. Priority only
settles ties between ticks already due together.
Fluids are the scheduler’s largest customer by a distance, and they exploit
the dedup key: Fluids.WATER and Fluids.FLOWING_WATER are different
registry objects, so one tick of each can be pending at one position. What
those ticks then do is fluids.
Where an appointment waits
Every LevelChunk owns exactly two containers, LevelChunk.blockTicks and
LevelChunk.fluidTicks, and they are the only place a live chunk’s tick waits
until a drain collects it (the four shapes a chunk
takes): a priority queue,
LevelChunkTicks.tickQueue, in ScheduledTick.DRAIN_ORDER, beside the dedup
set that decides what gets into it.
LevelTicks never scans those queues looking for work. It keeps
LevelTicks.allContainers, chunk key to container, and
LevelTicks.nextTickForContainer, chunk key to the earliest trigger time that
chunk holds, defaulting for an unknown chunk to the largest possible long. The
index is maintained by LevelTicks.chunkScheduleUpdater, the callback every
container is handed through LevelChunkTicks.setOnTickAdded when it
registers, and it acts only when the tick just added is the container’s new
head. A chunk with nothing due costs one map entry and one comparison per
level tick, and a chunk with an empty queue costs nothing at all — which is
what makes tens of thousands of loaded chunks affordable.
Registration follows the chunk’s life exactly: ChunkStatusTasks.full calls
LevelChunk.registerTickContainerInLevel (LevelTicks.addContainer, both
types), and ServerLevel.unload calls
LevelChunk.unregisterTickContainerFromLevel → LevelTicks.removeContainer,
which also drops the callback so an orphaned container can no longer touch the
index. In between, a tick aimed at a chunk with no registered container is
neither deferred nor queued: LevelTicks.schedule finds nothing, calls
Util.logAndPauseIfInIde, and the appointment ceases to exist. ClientLevel
short-circuits all of it — ClientLevel.getBlockTicks and
ClientLevel.getFluidTicks return BlackholeTickAccess.emptyLevelList, which
accepts every booking, answers false to every question and runs nothing, so
everything a client sees of a flowing fluid or a firing repeater arrives as
block-update packets. (ContainerSingleItem also lives in world/ticks, a
one-slot inventory interface with nothing to do with ticks — a packaging
accident, noted only so it does not confuse you.)
What one drain actually does
ServerLevel.tick calls LevelTicks.tick twice in its tickPending
section, blocks first and then fluids, each with the current game time and a
budget of ServerLevel.MAX_SCHEDULED_TICKS_PER_TICK, 65536 — a budget per
call, so 65536 block ticks and 65536 fluid ticks (the level
tick).
The whole section is skipped in a
debug world and whenever TickRateManager.runsNormally is false. Each call
is three phases.
Collect. LevelTicks.sortContainersToTick walks the index, not the
containers: it leaves a future entry alone, deletes one whose container has
vanished or emptied, corrects one whose head is later than the index claimed,
and tests a genuinely due one against LevelTicks.tickCheck. That predicate
is ServerLevel.isPositionTickingWithEntitiesLoaded, asked about the
chunk, not the position, and true only when the chunk is in
DistanceManager.inBlockTickingRange, its
ChunkHolder.getTickingChunkFuture has already succeeded, and
PersistentEntitySectionManager.areEntitiesLoaded holds (two graphs, one
store). A chunk that fails keeps its index entry
untouched and is asked again next tick — its ticks are late, never lost — and
one that passes moves into LevelTicks.containersToTick, a priority queue of
containers ordered by LevelTicks.CONTAINER_DRAIN_ORDER, on their heads.
LevelTicks.drainContainers polls the best container, takes one tick and
hands to LevelTicks.drainFromCurrentContainer, which keeps pulling from that
same container while its next tick is still due and still beats the next-best
container’s head — containers are re-heaped only when the winner stops
winning. Where a container goes next turns on the budget as much as on its
own head. Overtaken but still due, with budget left: back into the
container queue, to be polled again this tick. Head no longer due: back to
the index at its head’s time. Still due but the budget is spent: back to
the index, and asked again next tick.
Drained empty: neither. Then
LevelTicks.rescheduleLeftoverContainers runs over whatever is still sitting
in the container queue when the drain stopped and returns each to the index at
its head’s trigger time, already in the past — which gets it collected next
tick but buys it no place in the order, because
LevelTicks.CONTAINER_DRAIN_ORDER compares priority and sub-order and has no
time term at all.
Run. LevelTicks.runCollectedTicks drains LevelTicks.toRunThisTick in
order, moving each entry to LevelTicks.alreadyRunThisTick and handing its
position and type to ServerLevel.tickBlock or ServerLevel.tickFluid. Both
re-read the world there and run BlockBehaviour.BlockStateBase.tick or
FluidState.tick only if the block or fluid is still the one the appointment
named. A tick is a promise to a type, and that check is the whole of
cancellation for anything a block does: break the block and its pending ticks
evaporate with no cancellation code anywhere. The only code that removes a
pending tick outright is bulk — LevelChunkTicks.removeIf, through
LevelTicks.clearArea below. Clean up is
LevelTicks.cleanupAfterTick, emptying all four working collections including
LevelTicks.toRunThisTickSet, which is built lazily and only if somebody asks
LevelTicks.willTickThisTick. That list is the whole difference between the
two questions a block can ask. TickAccess.hasScheduledTick
reads the container and answers is one booked; LevelTickAccess.willTickThisTick
reads this already-collected list and answers is one about to run in this very
level tick — a thing TickAccess.hasScheduledTick can no longer see, because
LevelChunkTicks.poll freed the slot during collect.
The comparisons, and which is used where
| comparison | compares | used by |
|---|---|---|
ScheduledTick.DRAIN_ORDER | trigger tick, then priority, then sub-order | the priority queue inside every LevelChunkTicks |
ScheduledTick.INTRA_TICK_DRAIN_ORDER | priority, then sub-order — no time term | LevelTicks.drainFromCurrentContainer, comparing two already-due ticks |
LevelTicks.CONTAINER_DRAIN_ORDER | the same, applied to two containers’ heads | LevelTicks.containersToTick |
LevelChunkTicks.SUB_TICK_ORDERING | sub-order alone | LevelChunkTicks.pack, on save only |
ScheduledTick.UNIQUE_TICK_HASH | not an ordering — identity on type and position | LevelChunkTicks.ticksPerPosition and LevelTicks.toRunThisTickSet |
Time drops out of the second comparison because a container reaches the collect queue only once its head is already due, and once everything in play is due, time no longer discriminates.
Separating the phases has two consequences. A tick booked during the run
phase lands in its container after collect has finished, so it waits for a
later drain even at delay zero — the one exception being a block tick that
books a fluid tick at delay zero, which the fluid drain, running
afterwards in the same ServerLevel.tick, still catches. And the dedup slot
is released by LevelChunkTicks.poll during collect, not at run, so a tick
may book its own successor from inside its own run: exactly how a fluid keeps
flowing and how a repeater arms its turn-off.
That the run list outlives the run is what makes bulk edits correct across the
phase boundary. LevelTicks.copyAreaFrom, called by /clone in
CloneCommands, harvests matching ticks from LevelTicks.alreadyRunThisTick,
from LevelTicks.toRunThisTick and from the containers in the area, then
re-bases every sub-order above the highest it found so copies keep their
relative order without colliding with the originals. LevelTicks.clearArea
does the mirror image for the gametest framework
(StructureUtils.clearSpaceForStructure, GameTestInfo). Both touch block
ticks only — nothing in the game copies or clears fluid ticks by area.
A repeater, appointment by appointment
A repeater with RepeaterBlock.DELAY 1 — RepeaterBlock.getDelay doubles
it, so two game ticks — with a redstone wire behind it that goes to 15 and
then back to 0 one tick later.
sequenceDiagram
participant SL as ServerLevel
participant RB as RepeaterBlock
participant LTs as LevelTicks
participant LCTs as LevelChunkTicks
SL->>RB: neighborChanged — the wire behind went to 15
RB->>RB: DiodeBlock.<br/>checkTickOnNeighbor<br/>— unlocked, unpowered,<br/>so it should turn on
RB->>LTs: willTickThisTick at this position? no
RB->>SL: scheduleTick — delay 2, TickPriority.HIGH
SL->>SL: createTick — gameTime plus 2, HIGH, nextSubTickCount
SL->>LTs: schedule
LTs->>LCTs: schedule — the dedup slot is free, the queue takes it
LCTs-->>LTs: updateContainerScheduling — the index learns gameTime plus 2
Note over SL,RB: next tick, the wire drops to 0 — the diode books nothing and cancels nothing
Note over SL,LCTs: two ticks after the booking, in the level tick's pending-block phase
LTs->>LCTs: poll — the tick leaves the queue and the dedup set
LTs->>SL: tickBlock at this position, for Blocks.REPEATER
SL->>RB: still a repeater, so<br/>BlockBehaviour.<br/>BlockStateBase.tick
RB->>SL: setBlock POWERED true, with Block.UPDATE_CLIENTS
SL->>RB: the section write lands, then DiodeBlock.onPlace
RB->>SL: neighborChanged on the block it powers, then that block's other sides
RB->>SL: scheduleTick again — the turn-off, at TickPriority.VERY_HIGH
One repeater, from the wire behind it changing to the appointment for turning itself off again. The first note bar is the tick the page is built on — the wire went back to zero and the booking already made was neither cancelled nor noticed — and the second opens the tick in which the appointment runs.
The booking is a priority the block chooses, not one this queue assigns.
The repeater above asks for TickPriority.HIGH to turn on and
TickPriority.VERY_HIGH to turn off, and which of the seven each redstone
block asks for in which situation — and why a comparator cannot ask for the
two most urgent at all — is diodes and the
observer.
What this page supplies is what the priority then buys: the sub-tick order
above.
A booking cannot be called off. Nothing in the game cancels a single
scheduled tick: the only removal is the gametest framework’s bulk
LevelTicks.clearArea above. So a booking made while the input was
present survives the input going away, and the block finds out only when its
turn comes. That is the queue’s half of pulse extension; what a repeater does
when it gets that turn is the diode page’s.
Nothing here uses Block.UPDATE_NEIGHBORS (block update
flags has the bits), so the write in
the diagram above wakes no neighbour through Level.setBlock’s neighbour
channel; only its shape pass runs. How a
diode’s signal leaves instead is diodes and the
observer.
The other kind of turn: random ticks
The appointment book is one of the ways a block gets a turn, and the contrast with random ticks is what defines it: a random tick is booked by nobody, aimed at no block, and carries no promise.
It also reaches a different set of chunks, and the difference is one ring.
Random ticks walk ChunkMap.forEachBlockTickingChunk, which despite its name
is the entity-ticking set; the scheduled-tick gate,
ServerLevel.isPositionTickingWithEntitiesLoaded, reads the wider
block-ticking radius instead. So at the edge of a player’s simulation distance
there is a ring of chunks where appointments still come due and nothing is
ever chosen at random. How the level tick then spends the rule’s number, and
why a section of solid stone costs nothing, belongs to the level
tick.
Two things about that walk belong here, because they are about what a promise
is worth. The first is that the eligible blocks are decided at bootstrap, not
per tick: BlockBehaviour.BlockStateBase.isRandomlyTicking is baked into the
state at BlockBehaviour.BlockStateBase.initCache from
BlockBehaviour.isRandomlyTicking, which defaults to
BlockBehaviour.Properties.randomTicks, so unlike an appointment, which names
a type and is checked against the world when it comes due, a random tick’s
eligibility is settled before the world exists.
The second is that lava gets its random tick twice. LiquidBlock.isRandomlyTicking and
LiquidBlock.randomTick both delegate straight to the fluid, so a chosen lava
position runs LavaFluid.randomTick once through the block branch of that
loop and again through the fluid branch. It is the only fluid it happens to:
Fluid.isRandomlyTicking is false by default and LavaFluid.isRandomlyTicking
is the sole override. Water is never randomly ticked at all — every inch of
its motion is a scheduled tick.
Appointments that survive a restart
A SavedTick stores a relative delay rather than an absolute time, so a
chunk that unloads while the world runs on fires its ticks the same number of
ticks after it starts ticking again:
ScheduledTick.toSavedTick subtracts the current game time on the way out and
SavedTick.unpack adds the new one on the way back. LevelChunkTicks.pack
writes the pending list first and then the live queue sorted by
LevelChunkTicks.SUB_TICK_ORDERING, and SerializableChunkData stores the two
lists under block_ticks and fluid_ticks through SavedTick.codec (copy on
the server, encode on a worker, write on the IO
lane). On the way in,
SavedTick.filterTickListForChunk discards any saved tick whose position is
not in the chunk being loaded.
Coming back is two-stage. SerializableChunkData builds a LevelChunkTicks
for a chunk at ChunkStatus.FULL and a ProtoChunkTicks for one below it, and
the LevelChunkTicks constructor holds the saved list as
LevelChunkTicks.pendingTicks — not in the queue — while pre-seeding the
dedup set from it, so a fresh booking cannot double up a saved one that is not
unpacked yet. The saved ticks enter the queue only when ChunkMap.prepareTickingChunk reaches
ServerLevel.startTickingChunk → LevelChunk.unpackTicks →
LevelChunkTicks.unpack, which counts sub-orders up from minus the list’s
length. Every unpacked tick gets a negative sub-order, and
Level.subTickCount starts each session at zero and only rises, so a loaded
tick always sorts before anything this session booked at the same time and
priority.
Generation takes the same road. A ProtoChunk holds ProtoChunkTicks, which
records everything at delay zero and dedups under
SavedTick.UNIQUE_TICK_HASH, and WorldGenRegion exposes both of its
containers as a WorldGenTickAccess — a router that finds the right chunk per
position and answers LevelTickAccess.willTickThisTick with a flat false. At
promotion ProtoChunk.unpackBlockTicks turns that list into a
LevelChunkTicks in the pending state, and it becomes real at the same
ServerLevel.startTickingChunk a loaded chunk goes through — due immediately,
at the game time the chunk started ticking.
Questions players ask
Why did breaking one block stop a machine that was two ticks from firing?
A tick names a type. The appointment stays in the queue and still runs, but
ServerLevel.tickBlock re-reads the position, finds a different block, and
runs it to nothing.
Does /tick freeze stop scheduled ticks? Yes, and among the tick
commands it is the only one that does — a debug world skips the section too.
TickRateManager.runsNormally returns TickRateManager.runGameElements,
recomputed every tick as not frozen, or stepping — so /tick step runs the
tickPending section normally for the ticks it steps, and /tick sprint
clears the freeze flag outright for the length of the sprint
(ServerTickRateManager.requestGameToSprint) and puts back whatever it found
when the sprint ends.
Where do my ticks go when a chunk stops ticking? Nowhere. They sit in the chunk’s own queue, the index entry is left untouched, and the moment the chunk is block-ticking again the due ones are collected, as far as the drain’s budget goes. If the chunk unloads first they are written to disk with it. Outside generation and the test framework’s area clear, the only appointment lost is one booked into a chunk with no registered container.
For a 1.21-era reader.
BlockBehaviour.updateShapeno longer takes aLevelAccessor. It takes aLevelReaderand a separateScheduledTickAccess— a small interface for booking:ScheduledTickAccess.createTick,ScheduledTickAccess.getBlockTicks,ScheduledTickAccess.getFluidTicksand fourScheduledTickAccess.scheduleTickoverloads that compose them. Every waterloggable block books water’s tick from its own override of it.
Where to look
The appointment and the two things that decide its fate: ScheduledTick ·
ScheduledTick.UNIQUE_TICK_HASH · ScheduledTick.DRAIN_ORDER. Booking, from
the block’s side in: ScheduledTickAccess.scheduleTick ·
LevelAccessor.createTick · LevelChunkTicks.schedule. One drain, in its
three phases: LevelTicks.tick · LevelTicks.sortContainersToTick ·
LevelTicks.drainContainers · LevelTicks.runCollectedTicks ·
ServerLevel.tickBlock · LevelTicks.cleanupAfterTick. The gate the collect
phase asks:
ServerLevel.isPositionTickingWithEntitiesLoaded. And the disk form and the
two stand-ins for a live chunk’s containers: SavedTick · ProtoChunkTicks ·
BlackholeTickAccess.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
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.
Game events and vibrations
Verified against Minecraft 26.3 · Part IV · A footstep reaches a sculk sensor.
You walk across a stone floor and, eight blocks away, a sculk sensor’s
tendrils flick up and it pushes redstone power out of its side. Nothing
scanned for you: the step itself posted a GameEvent.STEP into
GameEventDispatcher.post, which walked the loaded chunk sections around you
and called each listener inside that listener’s own radius — all but the
catalyst’s inline — before Entity.move had finished. Nothing about that
broadcast waits for a later tick — and yet
what a player believes about sculk lives entirely in the delay, which the last
stages put back. A sensor never acts on an event in the game tick that
stamped it: VibrationSelector.chosenCandidate hands over a candidate only if
it was recorded on an earlier tick — which costs a mob’s footstep a whole
server tick and yours nothing, because the server handles a player’s movement
packet before the level tick moves the clock on. Everything else a player
thinks is stealth — the wool box, the crouch — is one of the gates between
that footstep and that slot, and the crouch does not work the way the folklore
says.
The cast
| class | what it decides | thread |
|---|---|---|
GameEvent | how far the news travels — one field, GameEvent.notificationRadius | — (a record) |
GameEventDispatcher | which sections are walked, and who is told inline rather than sorted | Server |
EuclideanGameEventListenerRegistry | which listeners in one section Y are close enough to be visited | Server |
DynamicGameEventListener | which section a moving listener is registered in | Server |
VibrationSystem.Listener | the gates between an event and a candidate | Server |
VibrationSelector | which of a tick’s candidates survives, and that none is used before the next tick | Server |
VibrationSystem.Ticker | the travel countdown, the particle, and the moment of arrival | Server, from the host’s own tick |
SculkSensorBlockEntity.VibrationUser | whether this sensor takes a vibration, and what it does with one | Server |
A game event is one number
GameEvent is a record of a single field, GameEvent.notificationRadius.
That is the whole type. Sixty-one constants live in
BuiltInRegistries.GAME_EVENT, fifteen of them GameEvent.RESONATE_1
through GameEvent.RESONATE_15, one per vibration frequency, so a
resonating block can re-emit the frequency it heard. All but three take
GameEvent.DEFAULT_NOTIFICATION_RADIUS, 16 blocks: GameEvent.JUKEBOX_PLAY
and GameEvent.JUKEBOX_STOP_PLAY are 10, GameEvent.SHRIEK is 32. The
radius is the dispatcher’s number, and each listener then applies its
own. Everything else rides beside the event in a GameEvent.Context — the
source entity and the affected block state, either of which may be absent.
The registry is a DefaultedMappedRegistry on step, which is narrower
than it sounds. DefaultedMappedRegistry.getValue and
DefaultedMappedRegistry.byId substitute GameEvent.STEP for an unknown
key, so a raw lookup that misses becomes a footstep — but GameEvent.CODEC
is a RegistryFixedCodec, which errors on an unknown name. Bad data in a
pack fails loudly; only the raw lookup silently becomes a step.
Listeners live in the chunk, one registry per section
A GameEventListener is four methods: a PositionSource
(BlockPositionSource for a block, EntityPositionSource for a mob, the
latter resolving a stored UUID against the level when it is asked, until it
finds the entity), a GameEventListener.getListenerRadius,
GameEventListener.handleGameEvent and a
GameEventListener.getDeliveryMode. They are stored per chunk section in
LevelChunk.gameEventListenerRegistrySections
(chunk anatomy), and LevelChunk.getListenerRegistry
creates an EuclideanGameEventListenerRegistry for a section Y the first
time anyone asks — including the dispatcher, merely walking past. One is
dropped again through LevelChunk.removeGameEventListenerRegistry, but
only when an EuclideanGameEventListenerRegistry.unregister empties it.
ChunkAccess.getListenerRegistry answers
GameEventListenerRegistry.NOOP: proto chunks and the client have none.
Block entities join on the chunk’s terms: LevelChunk.addGameEventListener
runs from LevelChunk.addAndRegisterBlockEntity on placement and from
LevelChunk.registerAllBlockEntitiesAfterLevelLoad when the chunk comes
back, asking EntityBlock.getListener — whose default returns the listener
of any block entity implementing GameEventListener.Provider (block
entities). A sensor’s section is fixed for
the life of the block. Entities move, so they carry a
DynamicGameEventListener instead: a listener plus the last SectionPos
it was filed under. Entity.updateDynamicGameEventListener is empty on
Entity and overridden by Warden and by Allay, which hands over two.
ServerLevel.EntityCallbacks drives it — DynamicGameEventListener.add,
DynamicGameEventListener.remove, and DynamicGameEventListener.move on
every section change. Its two halves are guarded separately and its record of
where it was advances either way, so a move whose old chunk is not loaded to
ChunkStatus.FULL leaves a stale registration behind, and one whose new
chunk is not takes the listener out of the world entirely.
The broadcast is a nested loop, and one listener is the exception
ServerLevel.gameEvent is one line into GameEventDispatcher.post, and
ServerLevel.gameEventDispatcher owns nothing between calls. GameEventDispatcher.post turns
the radius into a range of sections — 3 by 3 by 3 for the default 16, 5 by
5 by 5 for a shriek — fetches each chunk column once with
ServerChunkCache.getChunkNow, and visits each section’s registry.
EuclideanGameEventListenerRegistry.visitInRangeListeners resolves each
listener’s position, compares block-position distance squared against the
listener’s own radius squared — a sphere inside the dispatcher’s cube — and
calls GameEventListener.handleGameEvent there and then. No queue, no
ordering, no next-tick delivery: the broadcast is a nested loop inside
Entity.move, and the emitter’s method has not returned yet.
Two things follow. ServerChunkCache.getChunkNow returns null for a column
that is not already loaded and GameEventDispatcher.post skips it, so an
event at the edge of the loaded world is never delivered over the border.
And because a listener can be told mid-walk, a registry sets
EuclideanGameEventListenerRegistry.processing while it iterates and
defers every EuclideanGameEventListenerRegistry.register and
EuclideanGameEventListenerRegistry.unregister to the end of the visit.
One listener does wait. GameEventListener.getDeliveryMode is
GameEventListener.DeliveryMode.UNSPECIFIED everywhere except
SculkCatalystBlockEntity.CatalystListener, which answers
GameEventListener.DeliveryMode.BY_DISTANCE. Those are collected as
GameEvent.ListenerInfos, sorted by squared distance and delivered by
GameEventDispatcher.handleGameEventMessagesInQueue after the walk,
because a catalyst consumes the dead mob’s experience
(LivingEntity.skipDropExperience) and LivingEntity.wasExperienceConsumed
means only the first told gets any. Sorting is how the nearest one wins.
The gates between a footstep and a candidate
The cascade has a joint in it, and the prose above has already named it: the
walk is the dispatcher’s, and every gate after GameEventListener.getDeliveryMode
belongs to one listener deciding about itself. So it is two pictures. First, the
walk.
flowchart TD
A{"Entity.applyMovementEmissionAndPlaySound"}
A -->|"mid-stride"| X1["no event is posted at all"]
A -->|"a stride has passed"| C["ServerLevel.gameEvent posts GameEvent.STEP at the entity"]
C --> D["GameEventDispatcher.post walks 3 by 3 by 3 sections"]
D --> E{"ServerChunkCache.getChunkNow"}
E -->|"not loaded"| X2["skipped in silence, never retried"]
E -->|"loaded"| F{"EuclideanGameEventListenerRegistry.visitInRangeListeners"}
F -->|"out of its radius"| X3["not visited"]
F -->|"in range"| G{"GameEventListener.getDeliveryMode"}
G -->|"BY_DISTANCE"| Q["queued, sorted by distance, delivered after the walk"]
G -->|"UNSPECIFIED"| H["GameEventListener.handleGameEvent, inline"]
From a footstep to a listener that has been handed it: every exit on the left is silent — nothing is loaded, nothing is retried, nothing is told. The two right-hand ends are the only two ways the walk finishes, and the sculk catalyst is the only listener in the game that takes the queued one.
Both ends are a real delivery, and the difference is when. Everything below
continues from the inline one, and narrows again:
VibrationSystem.Listener.handleGameEvent is the vibration path, which
Allay.JukeboxListener is not on at all.
flowchart TD
H["VibrationSystem.Listener.handleGameEvent"]
H --> B1{"a vibration already in flight"}
B1 -->|"yes"| DROP["dropped, and nothing is remembered"]
B1 -->|"no"| B2{"VibrationSystem.User.isValidVibration"}
B2 -->|"fails"| DROP
B2 -->|"passes"| B3{"the listener's PositionSource"}
B3 -->|"does not resolve"| DROP
B3 -->|"resolves"| B4{"VibrationSystem.User.canReceiveVibration"}
B4 -->|"fails"| DROP
B4 -->|"passes"| B5{"VibrationSystem.Listener.isOccluded"}
B5 -->|"all six rays stopped"| DROP
B5 -->|"one gets through"| L["VibrationSelector.addCandidate"]
S["SculkSensorBlock.stepOn, standing on the sensor"] --> S2{"not a warden, and the sensor can activate"}
S2 -->|"VibrationSystem.User.canReceiveVibration"| L
Five refusals in the order they run, all of them landing in one place, and a short arm that arrives at the answer having asked almost none of them. Read the order rather than the tests: the busy check is first and costs nothing, the six-ray occlusion cast is last and costs the most, and the arm down the right is a player standing on the block.
The order is not the one a player would guess. The busy check comes first,
so a sensor already carrying a vibration ignores everything without
evaluating a single tag, and the occlusion raycast — much the most
expensive gate — comes last, after every cheap refusal has had its chance.
Before any of it, Entity.applyMovementEmissionAndPlaySound decides
whether there is an event to post, by accumulating Entity.moveDist and firing
only when it passes Entity.nextStep — which is why walking emits a footstep
per stride rather than per tick. Three gates ask the user rather than the
system: VibrationSystem.User.getListenableEvents supplies the tag
VibrationSystem.User.isValidVibration tests first, the user’s
PositionSource must resolve, and
SculkSensorBlockEntity.VibrationUser.canReceiveVibration refuses
GameEvent.BLOCK_DESTROY and GameEvent.BLOCK_PLACE at the sensor’s own
position — which is why placing a sensor does not set it off — refuses a
frequency of VibrationSystem.NO_VIBRATION_FREQUENCY, and otherwise defers
to SculkSensorBlock.canActivate: inactive only.
The short arm down the right of that figure is the gate a player most often
meets. SculkSensorBlock.stepOn runs from
Entity.applyEffectsFromBlocks every tick an entity stands on the block, and
calls VibrationSystem.Listener.forceScheduleVibration directly: no section
walk, no radius test, no occlusion raycast, and — the part that matters — no
VibrationSystem.User.isValidVibration, which is where the sneaking tag
lives. Crouching on a sensor does not quiet it. What the shortcut still asks
is VibrationSystem.User.canReceiveVibration, so an already-active sensor
stays quiet, and SculkSensorBlock.canActivate; and it still refuses a warden
by name. It ends where everything else ends, at
VibrationSelector.addCandidate, so it buys no time either.
The occlusion test is worth reading slowly.
VibrationSystem.Listener.isOccluded takes the source block’s centre,
nudges it a hundred-thousandth of a block along each of the six Direction
values in turn, and runs BlockGetter.isBlockInLine with a
ClipBlockStateContext looking for BlockTags.OCCLUDES_VIBRATION_SIGNALS.
It reports occluded only if all six rays are stopped, and returns the moment
one is not — but the six run a hundred-thousandth of a block apart to the same
point, so a block of wool the straight line passes through stops all six, and
only a line grazing a block’s edge can slip one past. A wool box is a box
because vibrations come from every side.
One footstep, and the ticks it takes to arrive
Every gate above passes, and the vibration still does not arrive for eight ticks of game time. This is where they go.
sequenceDiagram
participant Entity as Entity
participant SL as ServerLevel
participant VSL as VibrationSystem.<br/>Listener
participant VSel as VibrationSelector
participant VST as VibrationSystem.<br/>Ticker
participant SSVU as SculkSensor<br/>BlockEntity.<br/>VibrationUser
participant SSB as SculkSensorBlock
Note over Entity,SSB: tick T, a mob moves in its own tick
Entity->>SL: gameEvent, GameEvent.STEP at the entity's feet
SL->>VSL: the dispatcher's walk reaches it, inline
VSL->>VSel: addCandidate, a VibrationInfo stamped with game time T
Note over VST,SSB: still tick T, whenever the sensor's block entity ticks
VST->>VSel: chosenCandidate
VSel-->>VST: nothing, the candidate is not from an earlier tick
Note over Entity,SSB: tick T plus 1
VST->>VSel: chosenCandidate
VSel-->>VST: the VibrationInfo
VST->>SL: sendParticles, one VibrationParticleOption
VST->>VSel: startOver clears the slot
Note over VST,SSB: the countdown starts now, a block a tick
Note over Entity,SSB: tick T plus 8, and only now does it arrive
VST->>SSVU: onReceiveVibration — the event, the entities, the distance
SSVU->>SSB: activate, with the power and the frequency
SSB->>SL: setBlock PHASE active with POWER, then scheduleTick 30
Note over Entity,SSB: 30 ticks later deactivate, then 10 more before inactive
One footstep, and the eight ticks of game time it takes to be heard eight blocks away: a mob’s candidate stamped in tick T is refused in tick T and chosen in tick T plus 1, then travels a block a tick. That first wait is a whole server tick for a mob, which moves inside its tick, and none for a player, whose move is handled from its packet before the level tick moves the clock on.
VibrationSystem.Ticker.tick is the whole of the wait, and runs from
whoever hosts the listener: SculkSensorBlock.getTicker,
CalibratedSculkSensorBlock.getTicker and SculkShriekerBlock.getTicker for
the blocks — server-side only, and
already gated by Level.shouldTickBlocksAt on their own chunk — and
Warden.tick and Allay.tick for the mobs. One call does three things in
order: select, if nothing is in flight; send or re-send the particle; then
decrement the travel time and arrive if it has reached zero.
That particle is the only thing the client is told.
VibrationParticleOption carries the destination PositionSource and the
remaining tick count, and the client animates the flight from that alone:
ClientLevel.gameEvent is an empty method and there is no vibration
packet. When the block entity was loaded from disk,
VibrationSystem.Data.shouldReloadVibrationParticle answers yes and the
ticker re-sends the particle from a point interpolated along the path covered.
One slot, one tick late, and one refusal that waits
VibrationSelector holds at most one candidate — a VibrationInfo, which
carries the event, the source position, the distance and the entity behind it —
stamped with the game time it arrived. VibrationSelector.addCandidate takes an empty slot
unconditionally; against a candidate from the same tick it takes the
closer one, breaking a distance tie in favour of the higher frequency; and
against one held over from an earlier tick it does nothing at all,
because that one is already waiting to be consumed. Reading the slot does not
empty it: VibrationSelector.startOver does, and the ticker calls it in the
same breath as the read. That is the whole of
“the nearest event wins” — per listener, per tick, one slot.
The latency falls out of the read side. VibrationSelector.chosenCandidate
returns the candidate only if its stamp is strictly less than the current
game time, so one added during tick T is invisible for the rest of tick T
however the ordering falls, and the earliest it can be selected is the tick
after — the next server tick for a mob, and the same server tick for a player,
whose move is handled from its packet before the level tick moves the clock
on. Travel is measured from there:
VibrationSystem.User.calculateTravelTimeInTicks is the floor of the
distance — one block per tick — and the countdown’s first decrement happens
inside the same call that selected the vibration, so a source n whole
blocks away arrives n minus 1 ticks after selection, and anything closer
than two blocks arrives on the selecting tick itself.
Arrival is one call — VibrationSystem.User.onReceiveVibration, handed the
event, the source entity, the projectile’s owner and the distance from the
source’s block to the listener’s at arrival — and it can be refused.
VibrationSystem.User.requiresAdjacentChunksToBeTicking
is true for both sculk blocks, and VibrationSystem.Ticker will not deliver
unless all nine columns of the 3 by 3 around the listener are loaded and
pass Level.shouldTickBlocksAt — tickets and
loading is what “ticking” means. It does not drop
the vibration: the travel time is floored at zero, so the ticker asks again
every tick until the neighbourhood ticks. And the whole of
VibrationSystem.Data is written to disk under listener through
VibrationSystem.Data.CODEC, so a vibration survives a reload with its
countdown intact.
What arrival costs the block
VibrationSystem.VIBRATION_FREQUENCY_FOR_EVENT maps game events to
frequencies 1 to 15 and returns 0 for anything unmapped: a step, a swim and
a flap are 1, a death or an explosion 15. That number is kept by
SculkSensorBlockEntity.setLastVibrationFrequency and is what a comparator
reads out of an active sensor — SculkSensorBlock.getAnalogOutputSignal
answers 0 in any other phase. The redstone power is a different number:
VibrationSystem.getRedstoneStrengthForDistance is the larger of 1 and 15
minus the floor of 15 times the distance over the listener’s radius, using
a distance recomputed from the two block positions at arrival, not the
float stored when the candidate was made.
SculkSensorBlock.activate sets SculkSensorBlock.PHASE to
SculkSensorPhase.ACTIVE with that power, schedules a block tick
SculkSensorBlock.getActiveTicks out (30 for a plain sensor, the number
SculkSensorBlock.ACTIVE_TICKS names, and 10 for a calibrated one), runs
SculkSensorBlock.tryResonateVibration — which, for each of the six
neighbours in BlockTags.VIBRATION_RESONATORS, posts the matching
GameEvent.RESONATE_1 … GameEvent.RESONATE_15 at the neighbour’s
position, so an amethyst block beside a sensor rebroadcasts the frequency
it heard — and then emits GameEvent.SCULK_SENSOR_TENDRILS_CLICKING, the
single entry in GameEventTags.SHRIEKER_CAN_LISTEN. Shriekers do not hear
your footsteps; only standing on one sets it off directly. They hear sensors
hearing you.
Coming down takes two scheduled ticks. At 30, SculkSensorBlock.tick calls
SculkSensorBlock.deactivate, which drops the power to zero, moves the
phase to SculkSensorPhase.COOLDOWN and schedules another tick
SculkSensorBlock.COOLDOWN_TICKS (10) out; only that tick returns the
block to SculkSensorPhase.INACTIVE. In between the sensor is dark and
still refuses every vibration, because SculkSensorBlock.canActivate tests
for inactive.
The other listeners
| listener | radius | what it listens to | what a vibration does |
|---|---|---|---|
SculkSensorBlockEntity | 8 | GameEventTags.VIBRATIONS | activates for 30 ticks, power by distance, frequency on the comparator |
CalibratedSculkSensorBlockEntity | 16 | GameEventTags.VIBRATIONS | the same, but active for 10 ticks rather than 30, and when the block behind CalibratedSculkSensorBlock.FACING gives a redstone signal, only that exact frequency is accepted |
SculkShriekerBlockEntity | 8 | GameEventTags.SHRIEKER_CAN_LISTEN | needs a player behind the event, then SculkShriekerBlockEntity.tryShriek raises the warning level on a shrieker that can summon; when the shriek ends, darkness, and a warden at level 4 |
Warden | 16 | GameEventTags.WARDEN_CAN_LISTEN | anger through Warden.increaseAngerAt, a 40-tick MemoryModuleType.VIBRATION_COOLDOWN, and a disturbance location for WardenAi |
Allay | 16 | GameEventTags.ALLAY_CAN_LISTEN, note blocks only | AllayAi.hearNoteblock stores MemoryModuleType.LIKED_NOTEBLOCK_POSITION, after which it accepts that block and no other |
Allay.JukeboxListener | 10 | not a vibration at all | a plain GameEventListener for GameEvent.JUKEBOX_PLAY and GameEvent.JUKEBOX_STOP_PLAY — it makes the allay dance |
SculkCatalystBlockEntity.CatalystListener | 8 | not a vibration at all | on GameEvent.ENTITY_DIE, takes the mob’s experience as sculk cursors and blooms |
The tags are where the personalities live, and they are data
(tags): GameEventTags.WARDEN_CAN_LISTEN covers
shrieks and tendril clicks that GameEventTags.VIBRATIONS leaves out, and
leaves out the flap that sensors hear. The warden is also the one entity
whose Entity.dampensVibrations is unconditionally true — a dropped item
answers true too, but only while it holds something in
ItemTags.DAMPENS_VIBRATIONS — so the warden is invisible to every vibration
listener, wardens included, and the one entity
SculkSensorBlock.stepOn refuses by name. Its brain and the allay’s are
Part VI.
Questions players ask
Does sneaking make me silent? It makes six events silent.
GameEventTags.IGNORE_VIBRATIONS_SNEAKING holds GameEvent.STEP,
GameEvent.SWIM, GameEvent.HIT_GROUND, GameEvent.PROJECTILE_SHOOT,
GameEvent.ITEM_INTERACT_START and GameEvent.ITEM_INTERACT_FINISH, and
the test is Entity.isSteppingCarefully — whether the sneak key is down.
Open a chest while crouched and the sensor hears it. Sneak past one as a
player and CriteriaTriggers.AVOID_VIBRATION notes the advancement,
because sensors and wardens answer VibrationSystem.User.canTriggerAvoidVibration.
Why did my wool floor not stop it? Wool underfoot and wool in the way
are different tags doing different jobs. BlockTags.DAMPENS_VIBRATIONS on
the block being walked on kills the event at
VibrationSystem.User.isValidVibration, and ItemTags.DAMPENS_VIBRATIONS
does the same for a dropped item through ItemEntity.dampensVibrations.
BlockTags.OCCLUDES_VIBRATION_SIGNALS is the six-ray test, and it has to
stop all six.
Why does a sensor near the edge of the world miss? Two reasons, both
silent: GameEventDispatcher.post skips any column
ServerChunkCache.getChunkNow does not already have, and a sensor whose
3 by 3 neighbourhood is not ticking holds a finished vibration until it is.
The second is visible on the debug channel; the first is not, because the
broadcast happens only where a listener was visited —
DebugSubscriptions.GAME_EVENTS
and DebugSubscriptions.GAME_EVENT_LISTENERS, broadcast through
ServerLevel.debugSynchronizers
(debugging the running game).
Where to look
The broadcast, from the event to the door of a listener: GameEvent ·
GameEventDispatcher.post ·
EuclideanGameEventListenerRegistry.visitInRangeListeners ·
LevelChunk.getListenerRegistry. Then the gates, in the order the cascade
asks them: VibrationSystem.Listener.handleGameEvent ·
VibrationSystem.User.isValidVibration ·
VibrationSystem.Listener.isOccluded · VibrationSelector.addCandidate ·
VibrationSystem.Ticker.tick · VibrationSelector.chosenCandidate ·
SculkSensorBlock.activate. And the two doors out of this page:
SculkSensorBlock.stepOn for the shortcut, DynamicGameEventListener.move
for how a listener that walks stays filed.
The other index the world keeps about itself — where things worth walking to are, rather than what just happened — is points of interest. The brain behind a warden or an allay belongs to goals and brains.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Points of interest
Verified against Minecraft 26.3 · Part IV · A villager claims a bed.
A villager standing in the middle of a field at noon decides which bed is its
own. It is not near it, it is not looking at it, and it will not walk to it
for another six thousand ticks. AcquirePoi asked the index for the beds
with a free ticket within 48 blocks, took the five nearest, asked
PathNavigation for a path to all five at once, and the
moment Path.canReach came back true it called PoiManager.take and
decremented that bed’s ticket. Night has nothing to do with it. Hours later
SleepInBed will finally put it in the bed and set
AbstractBedBlock.OCCUPIED,
and because both occupied variants of a bed head are in PoiTypes.BEDS and
map to the same PoiTypes.HOME, that block change does not touch the record
at all: the claim and the occupied flag speak in one direction only. Going
to sleep tells the index nothing, and of the three behaviours that read the
flag back, the only one that turns it into a change in the record can do just
one thing with it — take a claim away. Neither is what a player would call
ownership.
The cast
| class | what it decides | thread |
|---|---|---|
PoiType | how many tickets a kind of block hands out and how close is close enough — a record of PoiType.matchingStates, PoiType.maxTickets, PoiType.validRange | static, built at bootstrap |
PoiTypes | the catalogue, and the block-state → type map PoiTypes.TYPE_BY_STATE that every lookup goes through | static |
PoiRecord | one position’s type and its PoiRecord.freeTickets counter | Server |
PoiSection | the records in one 16³ section, by section-relative position and by type, plus the Valid flag that decides whether the section is rebuilt on load | Server |
PoiManager | the index: a SectionStorage over the poi/ region files, the whole query family, and the village distance graph | Server |
ServerLevel | that a block change happened at all — ServerLevel.updatePOIOnBlockStateChange is the door every ordinary write goes through | Server, or the worldgen executor |
AcquirePoi | which POI a mob claims, and when to stop asking about one it cannot reach | Server |
ValidateNearbyPoi · SleepInBed | whether a remembered POI is still real, and what to do on arrival | Server |
A ticket is a claim nothing enforces
A point of interest is a block state the game has decided is worth going to.
There are twenty-one kinds, none data-pack-driven: PoiTypes.bootstrap
registers them into BuiltInRegistries.POINT_OF_INTEREST_TYPE and files every
one of their block states into PoiTypes.TYPE_BY_STATE, throwing at startup
if two types claim one state. PoiTypes.forState and PoiTypes.hasPoi are
the only questions anyone asks of that map.
A ticket is the claim. A PoiRecord starts with PoiType.maxTickets free
and hands them out one at a time: PoiRecord.acquireTicket decrements,
PoiRecord.releaseTicket increments, both refuse to run past their end of the
range, and both mark the section dirty. PoiRecord.hasSpace asks whether any
are left, PoiRecord.isOccupied whether any are gone — the second question
being the one the village graph reads. Neither says anything about who: a
record does not know its holder, a holder is not told when its record
disappears, and no state on the block corresponds to a claim.
stateDiagram-v2
[*] --> Free : block placed, PoiManager.add, PoiType.maxTickets free
Free --> Held : PoiManager.take, then PoiRecord.acquireTicket
Held --> Free : PoiManager.release, from one of four behaviours or the villager's end
Held --> Gone : the block changes, so PoiManager.remove
Free --> Gone : the block changes, so PoiManager.remove
Gone --> [*]
note right of Held : occupied, so its section is a village centre if the type is in PoiTypeTags.VILLAGE
note right of Gone : nothing is released, and the claimant is not told
The whole life of one ticket, and the thing to look for is what is missing. The only way back to Free is a villager letting go, through a behaviour in its brain or its end, and the two arrows into Gone bypass the claim entirely — the block changed, the record went, and the note says what nobody hears about it.
The asymmetry in that figure is deliberate on the release side and merely
survivable on the removal side. PoiManager.release throws when the
section is not there and PoiSection.release throws when the record is not,
which is why four of the five that release a ticket check PoiManager.getType
or PoiManager.exists first. Those five are ValidateNearbyPoi,
Villager.releaseAllPois (through Villager.releasePoi,
which checks the type and then tests it against Villager.POI_MEMORIES before
it dares), SetWalkTargetFromBlockMemory, GoToPotentialJobSite and
VillagerMakeLove. The last checks nothing, and gets away with it because the
position it releases is one
PoiManager.take handed back a moment earlier. PoiSection.remove on
a missing record only logs an error, so the removal path is allowed to be
wrong and the release path is not.
Erasing a memory is not a release, which is what makes the job-site behaviours
noisy and harmless: PoiCompetitorScan awards a contested site to whichever
villager has the higher Villager.getVillagerXp and makes the loser erase
MemoryModuleType.JOB_SITE, YieldJobSite has an unemployed villager hand
its MemoryModuleType.POTENTIAL_JOB_SITE to a neighbour whose profession the
site belongs to, and
AssignProfessionFromJobSite promotes the potential site to the real one.
None of those three touches a ticket — though GoToPotentialJobSite does,
GoToPotentialJobSite.stop giving back the ticket AcquirePoi took on the
potential site.
The catalogue
| types | the block | PoiType.maxTickets | PoiType.validRange |
|---|---|---|---|
PoiTypes.ARMORER PoiTypes.BUTCHER PoiTypes.CARTOGRAPHER PoiTypes.CLERIC PoiTypes.FARMER PoiTypes.FISHERMAN PoiTypes.FLETCHER PoiTypes.LEATHERWORKER PoiTypes.LIBRARIAN PoiTypes.MASON PoiTypes.SHEPHERD PoiTypes.TOOLSMITH PoiTypes.WEAPONSMITH | one work block each, in the same order — blast furnace, smoker, cartography table, brewing stand, composter, barrel, fletching table, all four cauldrons, lectern, stonecutter, loom, smithing table, grindstone | 1 | 1 |
PoiTypes.HOME | the bed’s head half only — PoiTypes.BEDS filters AbstractBedBlock.PART to BedPart.HEAD | 1 | 1 |
PoiTypes.MEETING | the bell | 32 | 6 |
PoiTypes.BEEHIVE PoiTypes.BEE_NEST | hive and nest | 0 | 1 |
PoiTypes.NETHER_PORTAL | the portal block | 0 | 1 |
PoiTypes.LODESTONE | the lodestone | 0 | 1 |
PoiTypes.LIGHTNING_ROD | the rod, every facing | 0 | 1 |
PoiTypes.TEST_INSTANCE | the test instance block | 0 | 1 |
Six of the twenty-one types are locatable but unclaimable.
PoiRecord.hasSpace is false for them forever, so nothing can
PoiManager.take one, and PoiRecord.isOccupied — which asks whether the free
count has moved off PoiType.maxTickets — is false forever too. Not that it
would matter: none of the six is in PoiTypeTags.VILLAGE, so none was ever a
candidate for a village centre. They are indexed so something can find one
fast: the bee’s hive search asks for PoiTypeTags.BEE_HOME
within 20 blocks and then filters through Bee.doesHiveHaveSpace, which asks
the BeehiveBlockEntity whether it is full. The index answers where and
something else answers whether. Three tags cut across the catalogue
(tags):
PoiTypeTags.ACQUIRABLE_JOB_SITE for the
thirteen professions, PoiTypeTags.BEE_HOME for the two hives, and
PoiTypeTags.VILLAGE for those thirteen plus PoiTypes.HOME and
PoiTypes.MEETING — fifteen types whose occupied records are what a village
is.
Where the index lives, and how it repairs itself
PoiManager extends SectionStorage, so it is the third of the three region
stores a dimension keeps (four folders, three of them the same
shape), and
the unit of storage is a chunk section and the unit of file a region:
ChunkMap builds it on the dimension’s poi/ folder, PoiManager‘s own
constructor naming DataFixTypes.POI_CHUNK, and PoiSection.Packed is the
on-disk shape
— a
Valid boolean and a list of Records, each a position, a type and a
free_tickets count. Tickets survive a restart, and so do the villagers’
memories of them, and nothing on load reconciles the two.
ChunkMap.tick runs PoiManager.tick under the profiler’s poi, which
writes dirty chunks for as long as the tick has time and then settles the
village graph (the level
tick
owns the
budget). Reads are the interesting half. The query family is a dozen shapes of
the same walk — PoiManager.getInSquare over a chunk range, PoiManager.getInRange
narrowing it to a sphere, and PoiManager.findClosest, PoiManager.getRandom,
PoiManager.getCountInRange, PoiManager.exists and PoiManager.getType
above them — and every one bottoms out in SectionStorage.getOrLoad, where a
section whose prefetch has not finished is read from disk synchronously, on
the
Server thread, blocking. That is why ChunkMap.scheduleChunkLoad fires
SectionStorage.prefetch beside the chunk’s own parse and joins the two
before either is used.
The repair runs on every chunk read. SerializableChunkData.read calls
PoiManager.checkConsistencyWithBlocks once per section with block data. If
the section is in storage, PoiSection.refresh rebuilds it — but only if its
Valid flag is false, and the rebuild reuses the existing PoiRecord objects
for positions that still have a POI, so a repair does not reset anybody’s
tickets. If it is not in storage, one is created and scanned. Both scans are
short-circuited by PoiManager.mayHavePoi, which asks
LevelChunkSection.maybeHas whether the palette holds any state
PoiTypes.hasPoi recognises (the palette and the ladder it
climbs), so a section
of plain stone is dismissed without one block read. The
Valid flag’s codec defaults to false: anything not explicitly written as
validated gets rescanned.
A record appears when a block changes, sometimes a task late
Level.setBlock calls Level.updatePOIOnBlockStateChange last, after the
neighbour updates, and on a ServerLevel that override compares
PoiTypes.forState of the old and the new state. Equal types mean nothing
happens — exactly the bed case, since a bed head that gains
AbstractBedBlock.OCCUPIED is still PoiTypes.HOME. Different types mean a
PoiManager.remove for the old and a PoiManager.add for the new, each
wrapped in a BlockableEventLoop.execute on the server.
That wrapper exists because WorldGenRegion.setBlock calls the same hook from the worldgen executor and the index is Server-thread-only. What it does not do is
defer the ordinary case: MinecraftServer.scheduleExecutables is false when
the caller is already the Server thread and not inside a task the loop is
running (ReentrantBlockableEventLoop.runningTask), so a block placed by a
player, a command or a mob during the tick body gets its record synchronously.
Deferral is the worldgen and nested-task case — there the record appears a
task later than the block it describes, and a read in between gets the old
answer.
Noon, and a bed forty-eight blocks away
A bed is placed, a villager claims it, and thousands of ticks later it sleeps in it. The index is written when the bed goes down and asked, and a ticket taken, when the villager claims it — not the time you would guess — and at night asked only whether the record is still there.
sequenceDiagram
participant SL as ServerLevel
participant PM as PoiManager
participant Brain as Brain
participant AP as AcquirePoi
participant PN as PathNavigation
participant SIB as SleepInBed
Note over SL,PM: any tick, Server thread
SL->>SL: setBlock puts the bed head down, and the two states differ
SL->>PM: add — a PoiRecord with one free ticket, the section dirty
Note over Brain,SIB: Activity.CORE, any hour of the day, HOME absent
Brain->>AP: at priority 10, and this evaluation is due
AP->>PM: findAllClosestFirstWithType HOME, HAS_SPACE, 48 blocks
PM-->>AP: the nearest five, past the retry cache
AP->>AP: VillagerGoal<br/>Packages.<br/>validateBedPoi<br/>re-reads all five
AP->>PN: createPath to all five at once, reach range 1
PN-->>AP: a Path whose canReach is true, getTarget is one bed
AP->>PM: take at that position, and one free ticket becomes zero
AP->>Brain: MemoryModuleType.HOME set to a GlobalPos
AP->>SL: broadcastEntityEvent 14
Note over Brain,SIB: thousands of ticks later, tick 12000, Activity.REST
Brain->>Brain: SetWalkTarget<br/>FromBlockMemory<br/>writes WALK_TARGET
Brain->>Brain: ValidateNearbyPoi, within 16 blocks: is the record still HOME
Brain->>SIB: within 2 blocks, and the bed is not OCCUPIED
SIB->>SL: setBlock, AbstractBedBlock.OCCUPIED true
SL->>SL: the state is HOME either way, so nothing is queued
Note over Brain,SIB: morning, REST leaves the brain, SleepInBed.stop or WakeUp clears the flag, the ticket stays
A bed placed and a bed slept in: the index is written at the top, asked, and
a ticket taken, at the claim in the middle, and at night asked only whether
the record still
exists. The last self-message is the page’s hook drawn as a non-event —
sleeping wrote AbstractBedBlock.OCCUPIED straight to the block, and because
the before and after are both PoiTypes.HOME the manager was never asked
about it.
The scan is cheap and the path is not. PoiManager.findAllClosestFirstWithType
turns a 48-block radius into a chunk radius of four, walks every section of
those chunks, filters by PoiManager.Occupancy.HAS_SPACE and sorts by
distance; AcquirePoi takes the first five and only then runs
VillagerGoalPackages.validateBedPoi, which re-reads each block to confirm it
is in BlockTags.VILLAGERS_CAN_SLEEP_ON_BED and not already AbstractBedBlock.OCCUPIED. Then
AcquirePoi.findPathToPois hands all five positions to
PathNavigation.createPath as one target set, at the reach range from
PoiType.validRange — one, for a bed. A villager’s constructor raises
PathNavigation.setRequiredPathLength to 48 so that this search can span the
scan range (pathfinding
owns that budget). Beds that failed before are held off by
AcquirePoi.JitteredLinearRetry, whose delay is cumulative: each attempt
adds another 40 to 79 ticks to that position’s own counter, capped at
AcquirePoi.JitteredLinearRetry.MAX_RETRY_PATHFINDING_INTERVAL, 400. A bed
behind a wall is checked ever more rarely until the gap reaches the cap,
twenty seconds, and then the marker, untouched that long, is dropped, so the
next check starts the saw again from the beginning. A successful claim clears
the whole cache.
The claim itself is five statements. PoiManager.take — alone among the
radius searches in having no PoiManager.Occupancy parameter, because it
always means HAS_SPACE — is called with radius 1 around the path’s target and a filter
accepting only that exact position, and calls PoiRecord.acquireTicket on
what it finds. Then MemoryModuleType.HOME is set to a GlobalPos, then
ServerLevel.broadcastEntityEvent sends event 14,
EntityEvent.VILLAGER_HAPPY — the green particle burst,
and the only thing an ordinary client learns of any of this. The last two
statements clear the retry cache and tell the debug channel. The memory is set
inside PoiManager.getType’s ifPresent, not inside PoiManager.take’s —
the take’s result is never consulted.
After the claim: the night shift
Brain.updateActivityFromSchedule, run from UpdateActivityFromSchedule at
priority 99 and only when more than twenty ticks have passed since it last did
anything, reads the villager’s schedule attribute —
EnvironmentAttributes.VILLAGER_ACTIVITY for an adult,
EnvironmentAttributes.BABY_VILLAGER_ACTIVITY for a child — at the villager’s
own position, and Timelines.VILLAGER_SCHEDULE puts the Activity.REST
keyframe at tick 12000 of a 24000-tick period
(environment attributes
owns the mechanism, goals and
brains
the pull that reads it; the old Schedule class is gone). Only then does the bed half of
the brain exist at all: VillagerGoalPackages.getRestPackage is where
SetWalkTargetFromBlockMemory, ValidateNearbyPoi for PoiTypes.HOME and
SleepInBed live, while the core package validates the job site and not the
bed. So between dawn and dusk a villager whose bed was mined keeps pointing at
a position with no record, and nothing tells it otherwise.
What the three of them do to the record
At night the three run in priority order, and only two of them ever touch a
ticket. SetWalkTargetFromBlockMemory at priority 2 is the walking half —
where it sends a villager and how it picks an intermediate target belong to
goals and brains;
what belongs here is its one effect on the index, which is that it gives up,
releasing the ticket and erasing the memory, once
MemoryModuleType.CANT_REACH_WALK_TARGET_SINCE has stood for more than 1200
ticks, the memory points into another dimension, or, with the bed more than
150 blocks off, a thousand tries find no step toward it. ValidateNearbyPoi
at priority 3 does
nothing at all unless the bed is within 16 blocks and in this dimension: then
it erases the memory if PoiManager.exists no longer agrees on the type, and
if the bed is AbstractBedBlock.OCCUPIED while this villager is not asleep — asleep
anywhere, not asleep here — it erases the memory. Whether it also releases the
ticket turns on a second question, asked of the world rather than of the
block: is a Villager actually sleeping inside that block’s box? If one is,
the memory goes and the ticket stays, the sleeper being presumed to hold it.
If none is, the ticket goes too. The two can disagree — a player asleep in the
bed sets the same flag — and that disagreement is the whole reason the second
question exists. SleepInBed, also priority 3, is the one that touches no
ticket at all. It
needs the villager within 2 blocks, the bed unoccupied and
SleepInBed.COOLDOWN_AFTER_BEING_WOKEN ticks since
MemoryModuleType.LAST_WOKEN; it calls LivingEntity.startSleeping, which is
what actually sets the flag. That is the second of the three reads of
AbstractBedBlock.OCCUPIED — a gate on entering the bed, not a change to the record —
and the third is VillagerGoalPackages.validateBedPoi, the filter
AcquirePoi runs over its best five.
Morning ends it twice over: when Activity.REST goes inactive,
SleepInBed.stop wakes the villager as the behaviour ends, and WakeUp, at
priority 0 in the core package, calls LivingEntity.stopSleeping on any
villager still asleep outside it. Either way the flag
clears and the ticket does not.
What makes a village
PoiManager.DistanceTracker is a SectionTracker — the same
DynamicGraphMinFixedPoint flood the ticket system’s two graphs use
(two graphs, one store), and the
only one of the five
outside server/level — over chunk sections instead of chunks. Its sources are the sections where
PoiManager.isVillageCenter holds: at least one record whose type is in
PoiTypeTags.VILLAGE and whose PoiManager.Occupancy is IS_OCCUPIED. They
sit at level 0 and the flood runs out to PoiManager.MAX_VILLAGE_DISTANCE,
six sections, past which the level is simply absent from the map.
A village is made of the sections in memory only. Alone in the query family,
PoiManager.isVillageCenter reads through SectionStorage.get rather than
SectionStorage.getOrLoad — the getter that answers null for a section not in
memory instead of pulling it off the disk — and it treats that null as not a
centre. So the graph is not a picture of what is in the save; it is a picture
of what has been read into memory this run, which nothing evicts. That is
deliberate: the flood is settled every tick from PoiManager.tick, and a
version of it that could touch the disk would turn the village graph into an
IO source in the middle of the level tick.
An unclaimed bed makes no village. A hundred empty beds are a hundred
records and zero sources; one villager taking one ticket lights the section up.
PoiManager.setDirty and PoiManager.onSectionLoad re-seed the source,
PoiManager.tick settles the flood every tick, and
PoiManager.sectionsToVillage settles it again before answering.
ServerLevel.isVillage is that distance being one section or less;
ServerLevel.isCloseToVillage takes the distance as an argument and refuses
anything past six.
That boolean is load-bearing far outside this system. BadOmenMobEffect
starts a raid only where ServerLevel.isVillage is true, VillageSiege needs
one to put zombies in, PatrolSpawner refuses a patrol when its chosen player
stands within two sections of one and CatSpawner insists on being within
two. Raid re-checks
it as the raid runs, and Raids sets the raid’s centre to the average
position of the occupied PoiTypeTags.VILLAGE records within 64 blocks.
Other readers of the index
| who | what it asks for | radius | PoiManager.Occupancy |
|---|---|---|---|
PortalForcer.findClosestPortalPosition | PoiTypes.NETHER_PORTAL, after PoiManager.ensureLoadedAndValid drags the chunks in | 16 going to the Nether, 128 coming back | ANY |
Bee | PoiTypeTags.BEE_HOME, then Bee.doesHiveHaveSpace for the real occupancy | 20 | ANY |
ServerLevel.findLightningRod | PoiTypes.LIGHTNING_ROD standing at the surface height | 128 | ANY |
LodestoneTracker | one position — is PoiTypes.LODESTONE still there | — | — |
LocateCommand | PoiManager.findClosestWithType for a type or a tag | 256 | ANY |
Raids | the records it averages into a raid’s centre | 64 | IS_OCCUPIED |
CatSpawner | more than four claimed PoiTypes.HOME nearby — asked only after ServerLevel.isCloseToVillage at two sections has already said yes | 48 | IS_OCCUPIED |
WanderingTraderSpawner | a PoiTypes.MEETING near a player, to arrive at | 48 | ANY |
NearestBedSensor | PoiTypes.HOME for MemoryModuleType.NEAREST_BED, babies only, no ticket taken | 48 | ANY |
PoiManager.ensureLoadedAndValid in the first row is the only caller that
forces the chunks themselves to load, where every other query reads the POI
file alone: for any nearby
section missing or invalid it pulls the chunk in at ChunkStatus.EMPTY, once
per chunk per server run since PoiManager.loadedChunks never forgets. The
index is on the debug channel too — LevelDebugSynchronizers.registerPoi,
LevelDebugSynchronizers.updatePoi and LevelDebugSynchronizers.dropPoi feed
DebugSubscriptions.POIS and DebugSubscriptions.VILLAGE_SECTIONS.
Questions players ask
Why is a villager sleeping in the bed I built for someone else? Because
the claim is a number in a file and the bed is a block, and neither knows the
other. PoiManager.take decremented a counter 48 blocks away hours before
anyone walked anywhere, and SleepInBed only ever checks that the bed is not
currently occupied — never who holds the ticket.
Why do villagers stop breeding when I take a bed away?
VillagerMakeLove.tryToGiveBirth calls VillagerMakeLove.takeVacantBed
first — a PoiManager.take for PoiTypes.HOME within 48 blocks, filtered by
reachability. No free ticket, no baby, and the pair get entity event 13,
EntityEvent.VILLAGER_ANGRY, instead. If the birth then fails the ticket is
released; if it succeeds,
VillagerMakeLove.giveBedToChild writes the baby’s MemoryModuleType.HOME
directly, with no AcquirePoi involved. This is the raw PoiManager.take,
which finds the first reachable match in section order rather than the
nearest, so the baby’s bed need not be the closest.
Why does the same bed get claimed twice after I break and replace it?
Breaking it runs PoiManager.remove and the record ceases to exist with its
ticket count — no release, no notification. The villager keeps its GlobalPos
until ValidateNearbyPoi next runs, which needs Activity.REST active and
the villager within 16 blocks, or SetWalkTargetFromBlockMemory gives it up.
Replace the bed first and there is a brand-new
record with a full ticket: another villager can claim it, while the one whose
memory already points there keeps pointing, so two villagers hold one bed.
Where to look
The record and the three things that happen to one:
PoiRecord.acquireTicket · PoiManager.add · PoiManager.take ·
PoiManager.release. Where they live and how they are repaired:
PoiManager.checkConsistencyWithBlocks · PoiSection.refresh ·
ServerLevel.updatePOIOnBlockStateChange. The claim, from the brain’s side:
AcquirePoi.create · ValidateNearbyPoi.create. And the flood that turns
claimed records into a village: PoiManager.DistanceTracker ·
ServerLevel.isVillage.
The other index in this corner of the tree — the fire-and-forget broadcast sculk sensors listen to — is game events and vibrations. The brain belongs to goals and brains, death and conversion to the entity lifecycle, the bed block to blocks and states.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
V · Blocks
Verified against Minecraft 26.3 · Part V · Everything that happens at the moment one block state replaces another: the click that asks for it, the write that performs it, and the four kinds of answer a block can give back.
You open a door and both halves swing. You flip a lever and the lamp across the room stays dark for a moment longer than you expected. Both are the same event underneath — a position in a chunk section stops holding one block state and starts holding another — and everything here is either choosing the state that goes in, performing the write, or being a block that answers one. A block has four ways to answer that this part teaches (a fifth, the game event, is Part IV’s), and the part is organised around them rather than around the three hundred block classes that use them: a neighbour update (something near you changed), a shape update (your neighbour’s state is now this — do you still fit?), a block event (do this at a named moment later in the tick) and a scheduled tick (do this in n ticks). A reader who has those four, and Part IV’s game events, can read any block in the game.
Two of them are the two channels a write can leave by, and that is where the door and the lamp part company: the neighbour channel exists only on the server, and the shape channel runs on both sides. The door’s other half comes down the channel your own client also runs, so it moves before the server has been asked; the lamp waits on the channel that exists only on the server. Half the surprises in this part are that sentence in another costume.
The shape of the part
Counting the two packages the atlas
lists for this part, that is 484 files and 58,507 lines — and
seven lectures is the fewest of any part with as many lines, on purpose. Most of those files are one Block subclass each, filling in a few of the hooks
BlockBehaviour and Block declare (blocks and states).
Part V is a hub and six spokes. The hub is blocks-and-states, and what each
spoke takes from it is not the state table — it is a piece of the write, drawn
there in two flowcharts and pointed at from every other page.
The six arrows out of the hub below are labelled with what it hands that
spoke; the five between spokes are the places two lectures share a mechanism.
flowchart TD
BS["1 · Blocks and states"]
BI["2 · Block interaction"]
BB["3 · Block breaking"]
BE["4 · Block entities"]
SD["5 · Signal and dust"]
PE["6 · Pistons and block events"]
DO["7 · Diodes and the observer"]
BS -- "the shape channel" --> BI
BS -- "the tail of an UPDATE_ALL write" --> BB
BS -- "when a block entity lives and dies" --> BE
BS -- "the neighbour channel" --> SD
BS -- "the flag word's rarer bits" --> PE
BS -- "an UPDATE_CLIENTS write's side effects" --> DO
BI -- "one click each, one ledger" --> BB
SD -- "what powers a piston" --> PE
SD -- "what a diode reads" --> DO
BE -- "redstone's one loose int" --> DO
BE -- "the moving block's entity" --> PE
The part as a hub and six spokes, numbered to the watch order. Six arrows
leave blocks-and-states carrying the piece of a write that lecture is about;
the five arrows between spokes are the mechanisms two spokes share, and every one of
them runs from an earlier lecture to a later one.
Before you start
Five pages are load-bearing, each for one reason. Chunk
anatomy is the
first half of every write here. The level
tick
matters because half of this part’s surprises are really claims about which
phase of a tick something ran in, and the server
tick
sits behind it for the two claims about what happens outside one: a packet
handler runs before the levels do, a connection is flushed after. Scheduled
ticks
is how a block gets a turn later, which each of the diode lecture’s three blocks uses. And
fluids owns the
FluidState that shares a StateHolder with every block state. Identifiers
and
registries
is assumed rather than used: the hub rests throughout on every block state
having been built into one table before any world existed.
One dependency runs the other way. Prediction and acknowledgement is Part X, and the two click lectures use three of its six windows — but its own scenario is a block placed against a wall, which needs this part’s vocabulary. So watch Part V first: both click pages state the contract in the same four sentences near their top, which is all either lecture needs. Part X is also where the third thing a player notices about this part lives, after the door and the lamp above — the block that appears under the crosshair before the server has heard about it.
Watch in this order
- Blocks and states — a right-click on stone puts one of oak stairs’ eighty states into the world. Every state the game will ever have was built before the world was, and the world only ever points into that table. The second half — what the write does — is the pair of figures the other six lectures point back at.
- Block interaction — the right click, in full: a door opened by hand. It fires no neighbour update at all, and the top half follows anyway.
- Block breaking — the same lecture’s other half: two clocks that agree without either telling the other its count, because the one comparison that matters happens in the packet drain rather than in the tick. Finish too early and the block usually comes back, then vanishes again, and nothing the player does with the button stops it.
- Block entities — a furnace smelts while nobody is looking. It tells nobody anything: the fire is a block state, the arrow is four ints from a menu, and both are a tick late by construction.
- Signal and dust — a lever, two dust, and the cascade. A line turning off is visited again at every intermediate value it passes through, none of which is ever sent to anybody, and the game ships a second implementation behind a flag that does not do it at all.
- Pistons and block events — the part’s deferral with no delay in it: the work waits for one named phase of the level tick rather than for a number of ticks, and usually gets it in the same tick — and the one place the client is handed a re-simulation rather than a result.
- Diodes and the observer — the part’s closer. Three blocks that learn about their neighbours three different ways, and the one whose entire job is noticing change turns out not to be listening on the channel that carries it.
Where the part stops
Part V owns the write’s tail and three of the four answers; it does not own most of the
blocks. 47% of the part’s lines are named on no page in the book, and of the blocks
that are taught, many are taught elsewhere: a block is usually the place some
other system surfaces, so it is taught where its scenario is — the sculk family with game events and
vibrations, LiquidBlock with
fluids, the containers with Part
VII, signs and chests as things drawn with Part
XI, the structure block with
jigsaw and templates,
the command block with Part
XIII. What
comes back here is the moment any of them writes a state.
Of what belongs to nobody, three lecture-sized mechanisms are declared here, and this is the sentence that says so rather than leaving the silence to be found. The
hopper is the first — HopperBlockEntity is named on one
other page, for its cooldown, and the transfer itself is explained on none.
Sculk spread is the second: SculkSpreader’s
charges walking through SculkBehaviour, SculkBlock and SculkVeinBlock,
where the page
that owns the catalyst owns it only as a listener. The third, and the
largest, is a family — BeaconBlockEntity, ConduitBlockEntity and the
trial-spawner and vault sub-packages, the last two of which other pages name in passing while their state machines go unexplained. Each waits for a second
edition.
Reference this part uses
Block update flags is the one to open
first, because every page here spends at least one flag word: the ten bits of
Level.setBlock’s argument and what reads each. Then
registries for Registries.BLOCK and
Registries.BLOCK_ENTITY_TYPE, packets for every
block update, block event and acknowledgement in one table, data
components for DataComponents.TOOL, game
rules for GameRules.BLOCK_DROPS, and math and
primitives for BlockPos,
Direction and the packings every page here assumes. The
glossary defines the four answers and the two
things they act on, all but the scheduled tick from pages in this part, and diagram
lanes explains the abbreviations these figures use.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Blocks and states
Verified against Minecraft 26.3 · Part V · A player right-clicks the top of a stone block holding oak stairs: one of the stair’s eighty pre-built states is chosen, and then written — and that write, drawn in two figures, is what the rest of this part points back at.
You are standing on stone with a stack of oak stairs, and you right-click the
top of the block. A moment later a stair is up there, facing away from you,
sitting on the bottom half of its cube. Nothing was constructed to make that
happen. Oak stairs have four properties — facing, half, shape,
waterlogged — and all eighty combinations of them were built before any
world existed, in the class initialiser of Blocks, and numbered into one
flat table, Block.BLOCK_STATE_REGISTRY. What a chunk stores points into that
table: each section keeps a small palette of those very states, and uses the
table’s own numbers once it holds more than 256 different ones. Both of the surprises about choosing a state fall out of
that single decision. Choosing a property allocates nothing:
StateHolder.setValue reads one cell out of a table of neighbours computed at
startup and hands back a state that already existed. And the table’s numbers are
not always checked: Block.getId answers 0 for a state its table has never
seen, and Block.stateById answers Blocks.AIR’s default state for a number
it does not know — so wherever the game reaches the table through that pair, a
state the two sides disagree about raises nothing at all. It quietly becomes
air.
Then the state has to go in, and that is the page’s second half and its wider job: the write is where a block state stops being a value and becomes an event, and the two channels it can leave by are not the same channel on both sides of the game. Six other lectures in this part are applications of that write, so it is drawn here in full.
The cast
| class | what it decides | thread |
|---|---|---|
Block | one kind of thing: which properties it has, what its default state is, and — through its static helpers — the id lookups, the drops and the shape-update helpers the rest of the game calls | built at class-initialisation, read from every thread after |
BlockBehaviour | most of the hooks a block may override, from BlockBehaviour.onPlace to BlockBehaviour.updateShape. Block extends it and adds registration and hooks of its own, Block.getStateForPlacement among them | as above |
BlockBehaviour.Properties | hardness, sound, map colour, whether it ticks — and the ResourceKey without which no block can be built at all | kept by the BlockBehaviour constructor, which copies about a third of its values out; each state copies others, hardness and map colour among them |
StateDefinition | the table: which properties this block has, in which order, and the full product of their values | built in the Block constructor |
Property | one axis — a name, a value type, and where a value sits in that axis | immutable, shared between blocks |
StateHolder | one state’s property values, and the table that answers what state am I if this property becomes that value | filled once by StateDefinition, read-only after |
BlockBehaviour.BlockStateBase | every hook asked of a state and forwarded to its block, and the caches that make collision and occlusion cheap | half-built in its constructor, finished by BlockBehaviour.BlockStateBase.initCache |
Block.BLOCK_STATE_REGISTRY | the integer a state is on the wire and in a section’s global palette — never on disk, where a state is its name and its properties | appended once per state, in the Blocks class initialiser |
Eleven classes and one Cartesian product
classDiagram
class BlockBehaviour.Properties {
hardness, sound, map colour, whether it ticks
setId first, or the constructor throws
}
class BlockBehaviour {
<<abstract>>
most hooks a block may override
}
class Block {
the registry holder and the static helpers
one StateDefinition, one default state
BLOCK_STATE_REGISTRY, over every state of every block
}
class Property {
<<abstract>>
a name, a value type, an index per value
}
class BooleanProperty {
two values, true at index 0
}
class IntegerProperty {
min to max, min never below zero
}
class EnumProperty {
any StringRepresentable enum
}
class StateDefinition {
propertiesByName, sorted by name
states, the whole Cartesian product
}
class StateHolder {
<<abstract>>
this state's own property values
the neighbours table
}
class BlockBehaviour.BlockStateBase {
<<abstract>>
every hook, forwarded to the block
the caches initCache fills
}
class BlockState {
a constructor, asState, two public codecs
}
BlockBehaviour.Properties ..> BlockBehaviour : kept by the constructor, a third copied out
BlockBehaviour <|-- Block
Property <|-- BooleanProperty
Property <|-- IntegerProperty
Property <|-- EnumProperty
Block --> StateDefinition : builds exactly one, in its constructor
StateDefinition --> Property : one axis each, collected by the Builder
StateDefinition --> BlockState : one per cell, built once and never again
StateHolder <|-- BlockBehaviour.BlockStateBase
BlockBehaviour.BlockStateBase <|-- BlockState
StateDefinition ..> StateHolder : fillNeighborsForState fills the table
The eleven classes a block state is made of, and the two hierarchies that
meet in it: a kind three classes deep down the left and a state three classes
deep down the right. StateDefinition joins them, building one BlockState
per cell of the product and filling each state’s table of neighbours.
Two things in that picture are the section’s: Property is extended by those
three classes and by nothing else, and BlockState inherits only down the
state side, though its parent class is nested inside BlockBehaviour. The rest of this section is what the
product is and how big it gets.
The kind, three classes deep
A block is a kind of thing — oak stairs, stone, water. A block state is
one exact configuration of that kind, and it is a block state, never a block,
that a chunk section stores, that a block update carries, that a model is chosen
for. The kind is spread over three classes, and the third is the one a reader
does not expect. BlockBehaviour is the base and holds most of the hooks;
Block extends it and adds the registry holder, the state table, hooks of its
own and the statics everything else in the game reaches for. The third is
BlockBehaviour.Properties, which both are constructed from — a builder that
must first be given an identity: BlockBehaviour.Properties.setId supplies the
ResourceKey, the loot table and the translation key are derived from it,
and the BlockBehaviour constructor throws Block id not set without one.
So every block the game builds goes through Blocks.register, which takes the
id from BlockItemIds or BlockIds and hands it to the builder on the way
past. None of that is data: a block has
no codec at all, so hardness, sound and map colour never serialise.
The table, sorted by name
Each Block constructor calls its own Block.createBlockStateDefinition,
collecting properties through StateDefinition.Builder.add — which rejects a
name outside lower-case, digits and underscore, a value name that breaks the
same pattern, a property with fewer than two values, and a duplicate name — and
then StateDefinition.Builder.create
builds every state the block will ever have. Zero properties gives one
singleton state, one property gives a row, and two or more gives the full
Cartesian product of every property’s values, each cell constructed through
a StateDefinition.Factory which for blocks is the BlockState constructor.
Eighty states of oak stairs: four facings, two halves, five shapes, two waterlogged values, every one of them a distinct object built before any world existed.
StateDefinition.propertiesByName is a sorted map, so the axes are ordered
by property name, not by the order the block added them — for stairs the two
orders happen to coincide, at facing, half, shape, waterlogged. Three things
follow. The order of the global state ids follows it, because the product is
built by walking that map. And so does the field order of BlockState.CODEC
and StateDefinition.propertiesCodec — which is not the same as saying a
state is written alphabetically on disk: NBT is a hash map there, and the
alphabetical forms are text: the command syntax BlockStateParser writes and
the one StateHolder.toString prints, neither of which goes near the codec, and
any SNBT print, which sorts every compound’s keys. And
StateDefinition.any is the first cell of the product, which the Block
constructor installs as the default state unless the block calls
Block.registerDefaultState itself. Since BooleanProperty.VALUES lists
true before false, a block that does not override its default gets true
for every boolean it has. That is why StairBlock sets
StairBlock.WATERLOGGED to false explicitly: the alternative is stairs that
are born full of water.
There are exactly three concrete kinds of Property and no
DirectionProperty — facing is an EnumProperty over Direction
(naming drift).
BlockStateProperties is the shared pool of 124 of them, and several
share a serialised name while being different objects:
BlockStateProperties.FACING, BlockStateProperties.FACING_HOPPER and
BlockStateProperties.HORIZONTAL_FACING are all facing on disk.
That pool, the four property classes and the twenty-four StringRepresentable
enums of its own that EnumPropertys range over — ChestType, NoteBlockInstrument,
StairsShape and the rest — are nearly the whole of the state/properties
sub-package: no behaviour, just the axes and their values. The rest is two
records that describe a family of blocks, BlockSetType and WoodType, and
RotationSegment’s arithmetic for sixteen-way rotations.
Two classes beside it read a state rather than being part of one, and are
worth a name because their scenarios are elsewhere: BlockPattern with
BlockPatternBuilder matches a three-dimensional arrangement of
BlockInWorld, which is how the game recognises a built wither, and
BlockStatePredicate is a StateDefinition turned into a test.
The state, a leaf
StateHolder is the generic state, shared with FluidState
(fluids). It holds its owner, two parallel arrays of
property keys and values, and StateHolder.neighbors — a two-dimensional
table, property index by value index, answering what state am I if this
property becomes that value. StateHolder.setValue walks the key array
comparing references to find the row, asks Property.getInternalIndex for
the column, and returns the object already sitting in that cell. It
allocates nothing and it never constructs. The table is installed once by
StateHolder.initializeNeighbors, and a second call throws. Because every
state is built once, StateHolder.equals is final and identity-based: two
states are the same only if they are the same object.
That reference comparison is why a property lookup can throw on a property
that looks right. States match their properties by identity and properties
match each other by value: Property.equals compares the value class and
the name, refined by IntegerProperty and EnumProperty to compare the value
list too. So two separately constructed properties can be equal to one another
and still make StateHolder.setValue throw Cannot set property … as it does
not exist. The constant in BlockStateProperties is the object the table was
built with; a look-alike is not.
BlockBehaviour.BlockStateBase extends it and is the state-to-block hop —
BlockBehaviour.BlockStateBase.getShape,
BlockBehaviour.BlockStateBase.canSurvive and the rest each forward to the
owning block with the state as the first argument. It is also where the
caches live, and they arrive in two waves. Its constructor copies the flat
values out of the block’s BlockBehaviour.Properties, and asks the block one
question, BlockBehaviour.useShapeForLightOcclusion. Everything else that has to
ask a virtual question — the fluid state, whether it random-ticks, the
occlusion shape and its six faces, sky-light propagation, light dampening,
and the BlockBehaviour.BlockStateBase.Cache of collision shape and sturdy
faces built for every block without a dynamic shape — is filled later, by
BlockBehaviour.BlockStateBase.initCache, because those questions may look
at other blocks and so cannot be answered until every block exists.
BlockState itself is almost empty: a constructor, a
BlockState.asState that returns this, and two public codecs —
BlockState.FULL_CODEC, and BlockState.CODEC, which writes a default state
as the bare block id. It exists so the generic
plumbing has a concrete type to name. BlockBehaviour.BlockStateBase is the
class people mean when they say block state.
The Blocks class initialiser is that second wave and the only caller of
BlockBehaviour.BlockStateBase.initCache: it walks
BuiltInRegistries.BLOCK, adds each state to Block.BLOCK_STATE_REGISTRY
and finishes it. Note what makes the result safe to share between the Server
thread, the Render thread, the chunk workers and the meshing pool — it is
not immutability, because those cached fields are non-final and written
long after the constructor. It is that the writes happen inside a class
initialiser, and every thread that later reaches a BlockState reaches it
through Blocks.
The id that answers air
That registry is where the opening’s second surprise lives, and it is worth
being exact about how far it goes, because the tolerance is a property of two
static methods and not of the id. Block.getId answers 0 for a state the
table has never seen and Block.stateById answers Blocks.AIR’s default
state for a number it does not know, so a disagreement between the two sides
raises nothing and quietly becomes air. That pair is behind block-break
particles, the falling-block spawn packet and
EntityDataSerializers.OPTIONAL_BLOCK_STATE. The single-block update is
stricter in both directions: ClientboundBlockUpdatePacket.STREAM_CODEC reads
and writes the same table through ByteBufCodecs.idMapper, which uses
IdMap.byIdOrThrow and IdMap.getIdOrThrow and fails the connection instead. The section update is
not: ClientboundSectionBlocksUpdatePacket writes through the tolerant
Block.getId and decodes with IdMapper.byId, which answers null.
Four decisions, four lookups
BlockItem.getPlacementState asks the block for a state and refuses if it
cannot have one. The default Block.getStateForPlacement returns the block’s
default state; StairBlock overrides it and makes four decisions, each of
them one StateHolder.setValue into the table above.
StairBlock.FACING is UseOnContext.getHorizontalDirection, which is
Entity.getDirection — the way the player is facing, so the tall side ends
up away from them. StairBlock.HALF is Half.BOTTOM when the clicked face
is the top, Half.TOP when it is the bottom, and otherwise decided by
whether the hit point is in the upper or lower half of the clicked block.
StairBlock.WATERLOGGED is whether the fluid already at the target position
is Fluids.WATER. Then StairBlock.SHAPE is computed by
StairBlock.getStairsShape from the partly built state: it looks at the
neighbour in the direction the stair faces, and a stair there of the same
half with a perpendicular facing gives StairsShape.OUTER_LEFT or
StairsShape.OUTER_RIGHT; failing that it looks at the neighbour in the
opposite direction for StairsShape.INNER_LEFT or
StairsShape.INNER_RIGHT; failing both, StairsShape.STRAIGHT. In each case
StairBlock.canTakeShape vetoes the corner if the stair on the far side is
already aligned with this one. The same routine runs again in
StairBlock.updateShape every time a horizontal neighbour changes, which is
how a straight stair turns into a corner when you build next to it.
Everything in front of that — the click, the reach check, the block-then-item
ordering, the packet and the ack — belongs to
block interaction and
prediction and acks. One sentence of it
matters here: the client runs the identical BlockItem.place under a
prediction, so the write below happens twice, once on each side, from the
same code. Almost everything that differs is inside the write; what
BlockItem.place itself does differently afterwards is to skip the
block-entity tag and the advancement trigger on the client, and the state that
lands is not affected by either.
BlockItem.placeBlock calls Level.setBlock with flags 11,
Block.UPDATE_ALL_IMMEDIATE.
The two update channels
This is the shape the rest of Part V refers back to. A write is two
half-writes with a re-read between them: LevelChunk.setBlockState changes
the world and runs the side effects that belong to the position (and one
that reaches its neighbours, below), then
Level.setBlock’s tail runs the side effects that belong to the
neighbourhood — and only if the state it reads back is the one it asked
for.
A write reads its flag word by bit, so here are the eight bits it gates on
before you meet them: 1 is Block.UPDATE_NEIGHBORS, 2
Block.UPDATE_CLIENTS, 4 Block.UPDATE_INVISIBLE, 16
Block.UPDATE_KNOWN_SHAPE, 32 Block.UPDATE_SUPPRESS_DROPS, 64
Block.UPDATE_MOVE_BY_PISTON, 256
Block.UPDATE_SKIP_BLOCK_ENTITY_SIDEEFFECTS and 512
Block.UPDATE_SKIP_ON_PLACE. Placement’s 11 is therefore neighbours,
clients and immediate — the combination Block.UPDATE_ALL_IMMEDIATE — so the
stair’s write passes every flag test below, and the steps it skips, it skips
for other reasons: there is no block entity to remove or make, and a stair has
no analog output. The figures are the order; the table under them is what
each step waits for.
flowchart TD
IN["Level.setBlock"]
IN -- "out of bounds, or debug" --> FALSE["returns false"]
IN --> EMPTY
subgraph CHUNK["LevelChunk.setBlockState"]
EMPTY{"air into an empty section"}
SEC["write the section"]
NOOP{"this exact state already"}
HM["the four live heightmaps"]
LIGHT["tell the light engine what moved"]
PRE["BlockEntity.preRemoveSideEffects"]:::server
AFT["BlockBehaviour.BlockStateBase.affectNeighborsAfterRemoval"]:::server
GUARD{"is the new block still there"}
ONP["BlockBehaviour.BlockStateBase.onPlace"]:::server
BE["create, keep or replace the block entity"]
NOTHING["hand back nothing"]
EMPTY -- "yes" --> NOTHING
EMPTY -- "no" --> SEC --> NOOP
NOOP -- "yes" --> NOTHING
NOOP -- "no" --> HM
HM --> LIGHT --> PRE --> AFT --> GUARD
GUARD -- "no" --> NOTHING
GUARD -- "yes" --> ONP
ONP -- "the state has a block entity, and the block is still there" --> BE
end
NOTHING --> FALSE
BE --> READ{"re-read: is it the state we wrote"}
ONP -- "otherwise" --> READ
The first half-write: what LevelChunk.setBlockState does belongs to the
position, bar the outgoing block’s word to its neighbours, and it has three ways
out that write nothing further — the two no-ops at the top and the guard on the
block, which all land in the same dead end. The three steps drawn in the server
colour are the server’s alone.
The re-read at the foot is the joint the whole section turns on. Only if the state that comes back is the one that went in does the second half run, and that second half is where most of a write’s reach past the position lives.
flowchart TD
READ{"re-read: is it the state we wrote"}
READ -- "no, and the tail is skipped" --> TRUE["Level.setBlock returns true"]
READ -- "yes" --> DIRTY
subgraph TAIL["the tail of Level.setBlock"]
DIRTY["Level.setBlocksDirty"]:::client
SEND["Level.sendBlockUpdated"]
NB["Level.updateNeighborsAt"]:::server
SHAPE["three shape passes"]
POI["Level.updatePOIOnBlockStateChange"]:::server
DIRTY --> SEND --> NB --> SHAPE --> POI
end
POI --> TRUE
The second half-write, which with the first is what the part’s other six lectures apply. Both edges out of the diamond return true — a write that fails its re-read skips the whole tail and still says it succeeded.
The figures are the order and the table is the gate: every step in them runs unconditionally unless a row here says otherwise, and the flag numbers are the ones the lead-in paired with their names.
| step | side | flags | also needs |
|---|---|---|---|
| write the section | both | — | — |
| the four live heightmaps | both | — | the two worldgen heightmaps are never touched |
| tell the light engine what moved | both | — | the section’s emptiness flipped (LevelLightEngine.updateSectionStatus), or the light properties differ (LevelLightEngine.checkBlock is queued) |
BlockEntity.preRemoveSideEffects | server | 256 clear | the block changed, and the new state does not keep the old entity. The removal after it runs on both sides |
BlockBehaviour.BlockStateBase.affectNeighborsAfterRemoval | server | 1 set, or 64 set | the block changed, or the new block is a BaseRailBlock |
BlockBehaviour.BlockStateBase.onPlace | server | 512 clear | — |
| create, keep or replace the block entity | both | — | the new state has a block entity, and the block is still there after BlockBehaviour.BlockStateBase.onPlace; then, either way, ChunkAccess.markUnsaved |
Level.setBlocksDirty | client | — | Empty on Level; the client re-meshes through LevelExtractor.setBlockDirty |
Level.sendBlockUpdated | both | 2 set, and 4 clear on the client | on the server, a chunk at FullChunkStatus.BLOCK_TICKING or better |
Level.updateNeighborsAt | server | 1 set | empty on Level, overridden on ServerLevel |
Level.updateNeighbourForOutputSignal | server | 1 set | the new state has an analog output |
| three shape passes | both | 16 clear, with 1 and 32 masked out of what they pass on | the update limit, which starts at Block.UPDATE_LIMIT, still positive. Indirect for the old state, direct for the new, indirect for the new |
Level.updatePOIOnBlockStateChange | server | — | empty on Level, overridden on ServerLevel |
Inside the chunk write
The section write, the four heightmaps and the light checks are the same on both sides, and belong to chunk anatomy. Three things after them are not.
BlockEntity.preRemoveSideEffects is the block entity’s last word before it
is unregistered — the chest scattering its contents, say. It needs the
server, and it needs Block.UPDATE_SKIP_BLOCK_ENTITY_SIDEEFFECTS clear; the
removal that follows it happens either way, on both sides
(block entities).
BlockBehaviour.BlockStateBase.affectNeighborsAfterRemoval is how the
outgoing block tells its neighbours it is gone — a piston head taking its base,
a broken lever telling the block it powered. It is not how a door drops its
other half:
DoorBlock does not override it, and the top half goes down the shape channel
instead (block interaction). It is easy to put in the wrong place:
it runs inside the chunk write, before the new state is even confirmed,
not in Level.setBlock’s tail with the other neighbour work. It needs the
server, it needs Block.UPDATE_NEIGHBORS set or the moved-by-piston bit,
and it also needs the block to have changed — a write that only changes a
property does not fire it, unless the new block is a BaseRailBlock. Rails are
the exception because a rail carries its own geometry in a property: changing
just the shape leaves the block the same and still moves the track, and
BaseRailBlock.affectNeighborsAfterRemoval is what tells the positions that
geometry reaches — above it when the old shape was a slope, and its own and the
one below for the rail kinds that never curve (powered, activator and detector
rails).
Then the chunk re-reads its own section. If a side effect has already
replaced the block just written with another, LevelChunk.setBlockState
returns nothing at all and Level.setBlock reports false. Otherwise
BlockBehaviour.BlockStateBase.onPlace runs — server-side, with
Block.UPDATE_SKIP_ON_PLACE clear — and then, if the new state has a block
entity and BlockBehaviour.BlockStateBase.onPlace has not replaced the block,
the block entity is created, kept or replaced. A block entity that disagrees
with the new state is logged as mismatched and thrown away.
Note that the diamond on the block and the re-read after it are not the same
test asked twice. The one inside the chunk write asks whether the block just
written is still there and answers false when it is not; the one back in
Level.setBlock asks the stricter question, whether the very state is, and
answers true anyway — it has a write to report, and what it skips is the
whole tail. So false means the chunk had nothing to change or a side effect
of the removal replaced the block, and true with nothing visible happening
means something later, BlockBehaviour.BlockStateBase.onPlace included,
changed the state or even the block first. Those three statements are the only ones that return
false: a position out of bounds, the server side of a debug world, and the
chunk write coming back with nothing.
Back in Level.setBlock’s tail
The first two steps of the tail are how the change becomes visible.
Level.setBlocksDirty is empty on Level itself; on the client it reaches
LevelExtractor.setBlockDirty, which re-meshes only if
ModelManager.requiresRender says the two states look different. The
broadcast that follows is gated on Block.UPDATE_CLIENTS, and then on
opposite conditions per side: the client also needs
Block.UPDATE_INVISIBLE clear, the server also needs the chunk to be at
FullChunkStatus.BLOCK_TICKING or better, so a write into a chunk that is
loaded but not yet simulating tells nobody. Worldgen is silent for a different
reason again: it never reaches Level.setBlock at all, writing through
WorldGenRegion instead.
The next two are the two update channels proper, and the difference
between them is the fact the rest of this part rests on. (The fifth and last
step of the tail is neither: Level.updatePOIOnBlockStateChange, which keeps
the village’s index of interesting blocks in step with the world and belongs to
points of interest.)
Neighbour updates are server-only. Level.updateNeighborsAt and
Level.neighborChanged are empty methods on Level, overridden only by
ServerLevel. Gated on Block.UPDATE_NEIGHBORS, the server hands the
position to its NeighborUpdater — a CollectingNeighborUpdater on every
real level, the alternative InstantNeighborUpdater being used by nothing
the game ships — which visits the six neighbours
in NeighborUpdater.UPDATE_ORDER (west, east, down, up, north, south),
calling each one’s BlockBehaviour.neighborChanged. Beside it,
Level.updateNeighbourForOutputSignal reaches the comparators in the four
horizontal directions, directly or through one redstone conductor
(diodes and the
observer).
Shape updates run on both sides. Level.neighborShapeChanged is
implemented on Level, and both a ServerLevel and a ClientLevel own a
CollectingNeighborUpdater. Unless Block.UPDATE_KNOWN_SHAPE is set,
Level.setBlock runs three passes with a decremented limit and with
Block.UPDATE_NEIGHBORS and Block.UPDATE_SUPPRESS_DROPS masked out of the
flags it propagates: BlockBehaviour.BlockStateBase.updateIndirectNeighbourShapes
for the old state, then
BlockBehaviour.BlockStateBase.updateNeighbourShapes for the new, then the
indirect pass again for the new. The middle one is the familiar one: six
neighbours in BlockBehaviour.UPDATE_SHAPE_ORDER — west, east, north, south,
down, up, a different order from the neighbour channel — each asked for a
new state through BlockBehaviour.BlockStateBase.updateShape and then
handed to Block.updateOrDestroy. The indirect passes are the hook a block
uses to reach past its six neighbours. Block.UPDATE_LIMIT, 512, is the
budget that stops this cascade — a recursion depth, and not the far larger
per-request budget over the neighbour channel (block
interaction).
And there is the catch. Block.updateOrDestroy writes the new state on
either side — but when the new state is air its destroy branch is
server-gated, going through Level.destroyBlock, which writes with flags 3
and posts GameEvent.BLOCK_DESTROY. So a shape update that turns a block into
nothing deletes it on the server, with a neighbour fan-out of its own, and
does nothing at all on the client, which then waits to be told.
The flag word
Those eight bits are not the whole word. There are ten, and what each is
called, what reads it and how the four named combinations decompose are the
catalogue’s: block update flags. One
number on this page is not a bit at all — Block.UPDATE_LIMIT is 512 like
Block.UPDATE_SKIP_ON_PLACE and means something entirely different.
Where to look
Follow the trace: Blocks.register and
BlockBehaviour.Properties.setId build a block,
StateDefinition.Builder.create builds its table and
StateDefinition.StateCollection.fillNeighborsForState fills the cells
StateHolder.setValue will later read. Block.BLOCK_STATE_REGISTRY is where
the numbers come from and
BlockBehaviour.BlockStateBase.initCache is what finishes each state. Then
StairBlock.getStateForPlacement chooses one, BlockItem.placeBlock writes
it, and LevelChunk.setBlockState and Level.setBlock are the two halves of
the write. Block.updateOrDestroy is the end of a shape update, and
NeighborUpdater.executeShapeUpdate — not named above — is where the updater
hands a queued one to the block.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Block interaction
Verified against Minecraft 26.3 · Part V · A player right-clicks the bottom half of an oak door, and the top half opens without a single neighbour update.
You are standing in front of a closed oak door, crosshair on its bottom half,
and you press the use key. Before the tick is over the door is open in your
client’s world, both halves of it, on your screen a frame or so later, and a packet is on its way to a server that has
not yet been asked. The obvious guess about how the top half found out is
wrong. Opening a door fires no neighbour updates at all — DoorBlock
writes with flags 10, and the neighbour bit is not among them — and the top
half follows anyway, down the shape channel, which is the half of the
update machinery the client also runs. That is why a door feels instant on a
laggy server and a redstone lamp does not.
The contract both halves run under. The client acts at once and remembers the state it overwrote, under a sequence number it sends with the action. The server’s
ClientboundBlockChangedAckPacketis a receipt for that number and not a verdict — once the server considers the client loaded, it is sent for actions the server refused exactly as for actions it allowed — and correctness comes from ordering instead: any correction the server means to send travels in the same tick and earlier in the stream than the receipt. A correction replaces what the client remembered rather than being weighed against it, so when the receipt arrives the client writes back whatever the entry now holds — and only where that differs from what is on screen. Prediction and acknowledgement owns that machinery; this page and block breaking are its two applications.
The cast
| class | what it decides | thread |
|---|---|---|
Minecraft | that a use-key press becomes one Minecraft.startUseItem, and that the main hand is tried before the off hand | Render |
MultiPlayerGameMode | the client’s copy of the whole decision, wrapped in one prediction | Render |
InteractionResult | whether the caller stops here, who swings, and what the hand ends up holding | a sealed interface of records, no thread |
BlockBehaviour.BlockStateBase | which of the two block hooks a state answers with, and when its six neighbours are asked to re-fit | either side |
DoorBlock | whether this door opens by hand, what it writes, and what the other half becomes | either side |
CollectingNeighborUpdater | the order queued updates run in, and where a runaway cascade is cut | whichever thread wrote the block |
ServerGamePacketListenerImpl | the gate list the packet must pass, and what each refusal answers with | Server |
ServerPlayerGameMode | the same inner order as the client, plus the advancement triggers | Server |
The whole click, both sides
sequenceDiagram
box Client
participant MC as Minecraft
participant MPGM as MultiPlayer<br/>GameMode
participant CL as ClientLevel
participant DB as DoorBlock
participant CNU as Collecting<br/>NeighborUpdater
end
Note over MC,CNU: one client tick, all of it before the packet leaves
MC->>MPGM: useItemOn, main hand first, with the BlockHitResult
MPGM->>MPGM: startPrediction opens sequence n
MPGM->>DB: useWithoutItem, after useItemOn returned TRY_WITH_EMPTY_HAND
DB->>CL: setBlock, the lower half with OPEN cycled, flags 10
CL->>CNU: shapeUpdate, six directions, limit 511
CNU->>DB: updateShape on the upper half, direction DOWN
CNU->>CL: setBlock, the upper half through Block.updateOrDestroy
DB->>CL: playSound, the clicker as except
The client’s share of one click up to the moment the use packet is sent. The two writes are the page’s subject: the lower half is written by the door, the upper half by the shape pass that the first write set off, and no neighbour update happens at all.
Only now does the use packet leave the machine. It carries the hand, the hit and the sequence number, and the server runs the identical inner order against its own world.
sequenceDiagram
box Client
participant MPGM as MultiPlayer<br/>GameMode
participant CL as ClientLevel
end
box Server
participant SGPL as ServerGamePacket<br/>ListenerImpl
participant SPGM as ServerPlayer<br/>GameMode
participant DB as DoorBlock
participant SL as ServerLevel
end
MPGM->>SGPL: ServerboundUseItemOnPacket, hand, hit and n
Note over MPGM,SGPL: no swing packet goes up: SUCCESS is predicted, so the client has already swung, and the server swings for the trackers itself
Note over MPGM,SL: server tick, packets drained before the levels tick
SGPL->>SGPL: ackBlockChangesUpTo n, then reach, hit box, height, spawn protection
SGPL->>SPGM: useItemOn, the same three-step order
SPGM->>DB: useWithoutItem
DB->>SL: setBlock lower half, then the shape pass writes the upper half
DB->>SL: playSound, everyone but the clicker, and gameEvent BLOCK OPEN
SGPL-->>CL: two ClientboundBlockUpdatePackets, the clicked position and the face neighbour
Note over MPGM,SL: still this tick, levels tick, then connections tick
SL-->>CL: ClientboundSectionBlocks<br/>UpdatePacket, both halves in one section
SGPL-->>CL: ClientboundBlockChangedAck<br/>Packet for n
CL->>CL: handleBlock<br/>ChangedAck<br/>ends prediction n
The server’s copy of the same click, and the three things that come back. The
receipt arrives last, and by the time it does the client has already been
corrected twice — which is why closing the prediction,
ClientLevel.handleBlockChangedAck, writes nothing.
One press, one hand at a time
Minecraft.handleKeybinds runs from the client tick and only when no screen
and no overlay is open. Every queued press of the use key becomes its own
Minecraft.startUseItem, unthrottled; Minecraft.rightClickDelay gates the
held-down auto-repeat alone, and Minecraft.startUseItem is what sets it, to
four ticks. The whole of that method sits behind one test —
MultiPlayerGameMode.isDestroying — so a use press arriving mid-dig does not
merely skip the delay, it is discarded entirely. A player already using an item
(drawing a bow, eating) gets no further either: Minecraft.handleKeybinds
drains the queued presses and throws them away before Minecraft.startUseItem
is reached. One level in, LocalPlayer.isHandsBusy stops a player who is
paddling a boat.
Inside, after LocalPlayer.isHandsBusy, the hands are tried in the order
InteractionHand.MAIN_HAND then InteractionHand.OFF_HAND. Each hand’s
stack must pass ItemStack.isItemEnabled — a disabled item aborts the whole
loop, not just its own hand — and then, for a BlockHitResult, the hand goes
to MultiPlayerGameMode.useItemOn. A InteractionResult.Success or an
InteractionResult.Fail ends the loop; only an InteractionResult.Pass
falls through to MultiPlayerGameMode.useItem (right-click air,
ServerboundUseItemPacket) and then to the off hand. The door returns
success on the main hand, so the off hand is never asked.
MultiPlayerGameMode.useItemOn sends a ServerboundSetCarriedItemPacket
first if the hotbar selection has moved
(MultiPlayerGameMode.ensureHasSentCarriedItem), refuses outright if the
target is outside the world border, and otherwise opens the prediction with
MultiPlayerGameMode.startPrediction, which allocates sequence n, runs the
whole client-side interaction inside it, and sends the
ServerboundUseItemOnPacket the interaction returned. The ledger that
prediction writes into belongs to the level rather than to the game mode:
ClientLevel.setBlock hands every state it overwrites while predicting to a
BlockStatePredictionHandler, so both halves of the door are recorded, and
both are restored together if the receipt arrives with the entries unchanged.
Block, then empty hand, then item
MultiPlayerGameMode.performUseItemOn and ServerPlayerGameMode.useItemOn
run the same three-step order. First the block is offered the item:
BlockBehaviour.BlockStateBase.useItemOn, whose BlockBehaviour.useItemOn
default answers InteractionResult.TRY_WITH_EMPTY_HAND. That sentinel — and
only when the hand is InteractionHand.MAIN_HAND — routes to
BlockBehaviour.BlockStateBase.useWithoutItem, whose
BlockBehaviour.useWithoutItem default is InteractionResult.PASS. If the
block consumed nothing, the item gets its turn through ItemStack.useOn,
provided the stack is non-empty and not held back by
ItemCooldowns.isOnCooldown. Sneaking skips the first two steps, but only
when some hand holds something: the guard is
Player.isSecondaryUseActive and a non-empty main or off hand, so an
empty-handed sneak still opens the door.
Three things differ between the two copies, and none of them is the inner
order. The server tests the block’s BlockBehaviour.requiredFeatures through
FeatureElement.isEnabled as its very first statement — a feature flag
narrowing the registry the click may reach (identifiers and
registries) — while the client
tests the same thing through ClientPacketListener.isFeatureEnabled inside
the not-sneaking branch. A spectator gets a flat
InteractionResult.CONSUME on the client, but on the server is routed to
BlockBehaviour.BlockStateBase.getMenuProvider and may end up with an open
container. And the advancement triggers exist only on the server:
CriteriaTriggers.ITEM_USED_ON_BLOCK when the item did it,
CriteriaTriggers.DEFAULT_BLOCK_USE when the empty-hand hook did, and
CriteriaTriggers.ANY_BLOCK_USE from the packet handler for anything that
consumed.
Those two hooks are how every right-clickable block in the game is written, and
the split between them is lopsided: 25 block classes override
BlockBehaviour.useItemOn, the ones that care what you are holding —
ComposterBlock taking something compostable, LecternBlock taking a book,
ChiseledBookShelfBlock reading the hit vector to pick a slot,
JukeboxBlock, CakeBlock, RespawnAnchorBlock — while 52 override
BlockBehaviour.useWithoutItem, the ones that answer an empty hand, of which
the door is one. Twelve override both, all six just named among them. A block
that does neither leaves the click to the item in the hand.
That is the whole family, and this door is an instance of it rather than a
special case.
The result is the vocabulary the whole pipeline turns on.
InteractionResult is a sealed interface of four records —
InteractionResult.Success, InteractionResult.Fail,
InteractionResult.Pass and InteractionResult.TryEmptyHandInteraction —
and the swing is part of it, not a separate decision.
InteractionResult.SUCCESS, InteractionResult.SUCCESS_SERVER and
InteractionResult.CONSUME are all InteractionResult.Success values
differing only in InteractionResult.SwingSource: the client animates at
once and the server animates for everyone else, the server animates for
everyone including the clicker, or nobody does. InteractionResult.consumesAction
is what both copies of the inner order test, and the server asks
InteractionResult.Success.shouldSwing before it swings; the client’s hand loop in Minecraft.startUseItem never asks it, and matches on
the record types instead. InteractionResult.Success carries two more answers
besides —
InteractionResult.Success.wasItemInteraction, which decides whether
Stats.ITEM_USED is awarded, and
InteractionResult.Success.heldItemTransformedTo, which both game modes use
to swap the stack the hand ends up holding.
The door writes ten
DoorBlock.useWithoutItem asks BlockSetType.canOpenByHand and, if the
answer is no, returns InteractionResult.PASS and lets the item try. For oak
it is yes: StateHolder.cycle flips DoorBlock.OPEN, and Level.setBlock
is called with flags 10 — Block.UPDATE_CLIENTS and
Block.UPDATE_IMMEDIATE, with Block.UPDATE_NEIGHBORS clear. Then
DoorBlock.playSound, LevelAccessor.gameEvent with GameEvent.BLOCK_OPEN or
GameEvent.BLOCK_CLOSE (posting is a nested loop inside the caller’s own
method), and
InteractionResult.SUCCESS. Exactly this code runs on both sides.
Ten is the whole story of the page, so it is worth spelling out as bits: 2 +
8, with 1 absent. Bit 2 is Block.UPDATE_CLIENTS and it broadcasts. Bit
8 is Block.UPDATE_IMMEDIATE, and on the client LevelExtractor.blockChanged
reads it not as immediate but as a player did this, which can buy the
section a priority remesh — LevelRenderer acts on that mark only when the
Chunk Builder option is set to prioritise nearby or player-affected
sections, which the fancy and fabulous graphics presets do, and fancy is the
preset a new client starts on. Bit 1
is Block.UPDATE_NEIGHBORS, and because it is absent Level.setBlock never
reaches its neighbour fan-out — which on the client would be a no-op anyway.
Nothing sets bit 16, Block.UPDATE_KNOWN_SHAPE, and that omission is what the
shape channel’s section below is about. What the flags then feed, and the rest of what a write
does, is the pair of flowcharts on blocks and
states; everything below is
the part of it the door walks.
The sound only you hear
DoorBlock.playSound passes the clicking player as the except entity, and
the two sides read that word oppositely. ClientLevel.playSeededSound plays
the sound only when the except entity is the local player;
ServerLevel.playSeededSound broadcasts a ClientboundSoundPacket to
everyone in range but them. So you hear your own prediction and never the
server’s copy — and since each side draws its own pitch from its own
Level.getRandom and its own seed from Level.soundSeedGenerator, your door
is genuinely a different sound from the one your friend heard. Every deferral
in this part that is not predicted does the opposite: a lever passes a null
except entity, so the clicker hears the server’s packet like everybody else
(signal and dust).
The shape channel, which both sides run
With Block.UPDATE_KNOWN_SHAPE clear and the update limit still positive,
the tail of Level.setBlock calls
BlockBehaviour.BlockStateBase.updateNeighbourShapes, which walks all six of
BlockBehaviour.UPDATE_SHAPE_ORDER — west, east, north, south, down, up —
and asks each neighbour, one top-level cascade at a time, whether it still
fits. Note which direction travels: for the block above, the level is handed
Direction.DOWN, the direction pointing from that neighbour back at the
door. Each hop costs one from the limit, so the upper half is written at 511
and asks its own neighbours at 510.
That is why the door is predictable and the redstone lamp is not: the shape
channel is the half of the update machinery a ClientLevel genuinely runs,
and the neighbour channel is the half whose methods are empty there (blocks
and states).
DoorBlock.updateShape answers four of its six callers with the state it was
given: the whole method is behind a test for the vertical axis, so the four
horizontals fall through to BlockBehaviour.updateShape, which returns the
state unchanged. The other two directions are where the door lives, and
besides leaving the state as it is there are three outcomes between them. Asked from the matching vertical direction — up
for a lower half, down for an upper — it returns the neighbour’s own state
with DoorBlock.HALF swapped to its own, so open, facing, hinge and
powered are copied wholesale, which is why the top half is already open by
the time it is written. Asked from that same direction when the neighbour is
not the other half, it returns Blocks.AIR. And a lower half asked from
Direction.DOWN returns air when DoorBlock.canSurvive fails — the block
beneath must be face-sturdy upward. Block.updateOrDestroy then compares: a
different non-air state becomes a Level.setBlock at the inherited limit, and
air becomes a Level.destroyBlock — which the hub shows is the one
branch of a shape update the client does not run (blocks and
states). That is the whole of
“break the bottom and the top pops”.
The updater underneath: a stack, drained depth-first
Every Level builds one Level.neighborUpdater, a
CollectingNeighborUpdater, in its constructor — on the client too. Requests
arrive as four small implementations of
CollectingNeighborUpdater.NeighborUpdates, three of them records:
CollectingNeighborUpdater.ShapeUpdate for the door’s case,
CollectingNeighborUpdater.SimpleNeighborUpdate and
CollectingNeighborUpdater.FullNeighborUpdate for a single neighbour, and
CollectingNeighborUpdater.MultiNeighborUpdate, one request that walks up to
six directions in NeighborUpdater.UPDATE_ORDER — the neighbour channel’s own
order, and not the shape one this door walks.
CollectingNeighborUpdater.addAndRun decides where a request goes by whether
a cascade is already running. The first one is pushed on
CollectingNeighborUpdater.stack and drained immediately by
CollectingNeighborUpdater.runUpdates; anything requested from inside a
running hook lands in CollectingNeighborUpdater.addedThisLayer and is
pushed on top of the stack before the current record’s remaining work, so the
drain is depth-first — a cascade finishes its children before its siblings.
NeighborUpdater.executeShapeUpdate and NeighborUpdater.executeUpdate do
the actual calls and wrap any throw in a crash report.
The chain limit is coarser than it looks and gentler than folklore says.
CollectingNeighborUpdater.count increments once per request, not per
depth level and not per block touched — a
CollectingNeighborUpdater.MultiNeighborUpdate is one
request that can expand to six calls — and past
CollectingNeighborUpdater.maxChainedNeighborUpdates further requests are
silently dropped after a single logged error, never a crash. The count is
reset when the outermost cascade unwinds, so the budget is per top-level
cascade. It is not a game rule: it is the max-chained-neighbor-updates
line in server.properties
(DedicatedServerProperties.maxChainedNeighborUpdates, default one million,
read through DedicatedServer.getMaxChainedNeighborUpdates), with
MinecraftServer.getMaxChainedNeighborUpdates hard-coding the same number
for the integrated server and ClientLevel passing the literal. Keep it
distinct from Block.UPDATE_LIMIT, the 512 that bounds nested writes: that
one counts recursion depth and is what the door’s 511 and 510 come from.
The gate list, and what each refusal answers with
ServerGamePacketListenerImpl.handleUseItemOn tests in this order, and the
interesting column is the second one — the refusals do not answer alike, and
one of them lies. The reach the third gate measures is an attribute, and
players have two of them — one for blocks and a shorter one for entities
(player anatomy).
| the gate | what the client gets when it fails |
|---|---|
ServerGamePacketListenerImpl.hasClientLoaded | nothing, not even the receipt |
ItemStack.isItemEnabled on the held stack | nothing |
Player.isWithinBlockInteractionRange, with 1.0 of slack | nothing |
| the hit location lying within one block of the clicked block’s centre on every axis — a 2×2×2 box, not the block | nothing, plus a server-side log line naming the player |
above LevelHeightAccessor.getMaxY or below LevelHeightAccessor.getMinY | ServerPlayer.sendBuildLimitMessage — an action-bar line, and no block update |
MinecraftServer.isUnderSpawnProtection | ServerPlayer.sendSpawnProtectionMessage, plus both block updates |
a pending teleport, or ServerLevel.mayInteract refusing for the world border | ServerPlayer.sendBuildLimitMessage — you are told you are building too high, whatever the real reason — plus both block updates |
| everything passed | ServerPlayerGameMode.useItemOn runs, plus both block updates |
The two block updates are a ClientboundBlockUpdatePacket for the clicked
position and one for the block on its clicked face, sent whatever the
interaction returned — and they sit inside the branch below the build-height
test, which is why a click that was out of reach or off the block is answered
with silence while a click into spawn protection is answered with the
truth. The door’s other half is in neither: it reaches the client with
everyone else’s copy, through ChunkHolder.broadcastChanges, which turns two
changed positions in one section into a single
ClientboundSectionBlocksUpdatePacket (and into two
ClientboundBlockUpdatePackets when the halves straddle a section boundary).
ServerGamePacketListenerImpl.ackBlockChangesUpTo was called after the first
gate and before the rest, and ServerGamePacketListenerImpl.tick emits the receipt when
MinecraftServer.tickChildren reaches connections — after the levels have
already broadcast (the server tick).
The other half of the same lecture
Left-click is this contract with a different pipeline:
Minecraft.startAttack reaches MultiPlayerGameMode.startDestroyBlock, which
opens its own prediction and sends a ServerboundPlayerActionPacket instead,
and the block hook is BlockBehaviour.BlockStateBase.attack. Nothing about the
ledger changes; everything about the clock does, because breaking the next
page’s stone takes eight ticks where opening takes none. Block breaking
takes it from there, and the two pages are deliberately the same shape.
Questions players ask
Why can’t I open an iron door by hand? Because
DoorBlock.useWithoutItem’s first question is BlockSetType.canOpenByHand,
false on BlockSetType.IRON and BlockSetType.GOLD and true on
BlockSetType.COPPER. Nothing on this path reads
BlockTags.WOODEN_DOORS — the copper door proves it, since it opens by hand
and is not in that tag. The tag only feeds other tags, the axe’s and the
doors mobs may open among them. Brain-driven mobs ask that one:
InteractWithDoor reads BlockTags.MOB_INTERACTABLE_DOORS, the wooden doors
and the copper ones, while the older goals read
DoorBlock.isWoodenDoor, which is BlockSetType.canOpenByHand under
another name.
Why does opening a door lag a busy server when nothing is powered?
Because the write still reaches ServerLevel.sendBlockUpdated, which
compares the old and new collision shapes and, when they differ, walks every
navigating mob in the level asking whether the change is worth a re-route —
the one place the world pushes back on pathfinding, and unbounded by distance
(pathfinding).
A door changes shape every time it moves. None of that exists on the client,
which is one more reason your half of the click is the fast half.
Where to look
The click comes in at Minecraft.handleKeybinds and
Minecraft.startUseItem, reaches the level through
MultiPlayerGameMode.useItemOn and MultiPlayerGameMode.performUseItemOn,
and is answered in InteractionResult. The two block hooks a state offers are
BlockBehaviour.BlockStateBase.useItemOn and
BlockBehaviour.BlockStateBase.useWithoutItem, and DoorBlock.useWithoutItem
is this door’s. Level.setBlock and
BlockBehaviour.BlockStateBase.updateNeighbourShapes are the write and the
shape cascade, CollectingNeighborUpdater.addAndRun the queue underneath it,
and DoorBlock.updateShape with Block.updateOrDestroy is where the top half
is decided. On the server, start at
ServerGamePacketListenerImpl.handleUseItemOn for the gates and
ChunkHolder.broadcastChanges for how the other half reaches everyone else.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Block breaking
Verified against Minecraft 26.3 · Part V · A survival player holds left-click on stone with an iron pickaxe for eight ticks: two clocks that agree without talking, one loot roll, one cobblestone.
Hold the button on a stone block and two programs start counting. The client
adds a fraction to MultiPlayerGameMode.destroyProgress every client tick and
paints the crack; the server sets ServerPlayerGameMode.destroyProgressStart
to the tick the dig began and recomputes, from scratch, how far along it ought
to be. Between the first packet and the last, neither clock is ever mentioned
on the breaker’s own connection — a ServerboundPunchPacket goes up and a
ClientboundLevelEventPacket comes down every tick, for the swing and for the
particles and the sound, and neither says how far along the dig is; the only
progress that goes on the wire at all is the server’s, to other players, so
that they see your cracks — and on the eighth tick the two answers are the same
number. That agreement is what the whole design rests on, and it is
also why the failure mode is so strange: once the client has finished,
releasing the button cancels nothing. A client that finishes too early gets
a deferral, not a rejection. The receipt for the STOP goes out in the same tick, the client
dutifully puts the stone back — and then watches it vanish a second time when
the server’s deferred break lands, with nothing the player can do in
between.
The contract both halves run under. The client acts at once and remembers the state it overwrote, under a sequence number it sends with the action. The server’s
ClientboundBlockChangedAckPacketis a receipt for that number and not a verdict — once the server considers the client loaded, it is sent for actions the server refused exactly as for actions it allowed — and correctness comes from ordering instead: any correction the server means to send travels in the same tick and earlier in the stream than the receipt. A correction replaces what the client remembered rather than being weighed against it, so when the receipt arrives the client writes back whatever the entry now holds — and only where that differs from what is on screen. Prediction and acknowledgement owns that machinery; block interaction and this page are its two applications.
The cast
| class | what it decides | thread |
|---|---|---|
Minecraft | that the button is down and the crosshair is on a block, and whether this tick starts a dig or continues one | Render |
MultiPlayerGameMode | the client’s clock: accumulated progress, the five-tick pause after a break, when to predict the removal and send STOP | Render |
ClientLevel | the predicted air, and the crack overlays — every breaker’s, including the local player’s | Render |
ServerGamePacketListenerImpl | which of the nine ServerboundPlayerActionPacket.Actions this is, and when the receipt for its sequence is flushed | Server |
ServerPlayerGameMode | the server’s clock, the reach and permission gates, the 0.7 verdict, and the deferral | Server |
BlockBehaviour.BlockStateBase | hardness, whether the block needs the right tool for drops, and the per-tick fraction | either |
Tool | how fast this stack mines this block, and whether it drops — two separate answers from one rule list | either |
Block | the particles and sound event, the stat, the exhaustion and the loot roll | Server, and the breaker’s own client for the particles and sound |
One dig, end to end
sequenceDiagram
box Client
participant MC as Minecraft
participant MPGM as MultiPlayer<br/>GameMode
participant CL as ClientLevel
end
box Server
participant SGPL as ServerGamePacket<br/>ListenerImpl
participant SPGM as ServerPlayer<br/>GameMode
participant SL as ServerLevel
end
Note over MC,SL: client tick 1: the button goes down, and the same lap runs the first continue too
MC->>MPGM: startDestroyBlock, the block is not air
MPGM->>CL: destroyBlockProgress, stage -1 at progress 0
MPGM->>SGPL: ServerboundPlayerAction<br/>Packet START, N
MC->>MPGM: continueDestroyBlock adds the first 0.133
MPGM->>CL: destroyBlockProgress, my own crack at stage 1
SGPL->>SPGM: handleBlockBreakAction, the start tick recorded
SPGM->>SL: destroyBlockProgress, everyone else's crack within 32 blocks
SGPL-->>CL: ClientboundBlockChangedAck<br/>Packet N, nothing to reconcile
loop client ticks 2-8, beside server ticks
par the client adds
MC->>MPGM: continueDestroyBlock adds 0.133
MPGM->>CL: destroyBlockProgress, my own stage
MC->>SGPL: ServerboundPunchPacket, no fields at all
and the server recomputes the whole dig and throws the answer away
SPGM->>SPGM: incrementDestroy<br/>Progress
SPGM->>SL: destroyBlockProgress, only when the tenth changes
SPGM->>SL: levelEvent with no source, sound every fourth tick
SL-->>CL: ClientboundLevelEventPacket, the face but no progress
end
end
The two clocks, with the machines boxed and the loop in parallel compartments that have no order between them: the client adds a fraction, the server recomputes the lot, and what crosses between the boxes, a punch up and a level event down, carries neither clock’s count, which is the page’s whole argument.
The eighth add is where the two stop being symmetrical. It reaches about 1.067 on the client, and the client acts on its own number first.
sequenceDiagram
box Client
participant MPGM as MultiPlayer<br/>GameMode
participant CL as ClientLevel
end
box Server
participant SGPL as ServerGamePacket<br/>ListenerImpl
participant SPGM as ServerPlayer<br/>GameMode
participant SL as ServerLevel
participant Block as Block
end
Note over MPGM,Block: still client tick 8: the eighth add reaches about 1.067
MPGM->>CL: setBlock to air under flags 11, inside prediction M
MPGM->>SGPL: ServerboundPlayerAction<br/>Packet STOP, M
Note over MPGM,Block: a server tick, STOP handled off the task queue
SGPL->>SPGM: handleBlockBreakAction STOP, its own 1.067 clears the 0.7 bar
SPGM->>Block: playerWillDestroy, event 2001 to all but the breaker
SPGM->>SL: removeBlock, the fluid-or-air state under flags 3
SPGM->>Block: playerDestroy, once ItemStack.mineBlock has spent a durability point
Block->>SL: the blocks/stone roll, then addFreshEntity with the ItemEntity
Note over MPGM,Block: same tick, later: the levels broadcast, then the connections flush
SL-->>CL: ClientboundBlockUpdatePacket air, absorbed
SGPL-->>CL: ClientboundBlockChangedAck<br/>Packet M, air is already there
The break itself, in the order the server runs it. The client writes the air first and on its own authority, and everything that comes back — the block update and the receipt — finds a world that already agrees.
Two clocks, and the plus one that makes them agree
BlockBehaviour.getDestroyProgress is the shared formula, and both sides call
it through BlockBehaviour.BlockStateBase.getDestroyProgress with the same
arguments. It answers the fraction of the block broken per tick: the
player’s speed, divided by the block’s hardness, divided by 30 when
Player.hasCorrectToolForDrops says yes — which it does for every block that
does not require a tool at all — and by 100 when it says no. Hardness −1
returns zero forever. Hardness zero is not special-cased, so an instabreak
block divides by zero and returns infinity: that, and not a branch on
hardness, is what sends the START handler down its insta-mine path on the
first tick.
Player.getDestroySpeed builds the numerator from Inventory.getSelectedItem
in one pass, and the surprising parts are all constants rather than data. The
stack’s ItemStack.getDestroySpeed gives the base. If that is above 1.0 —
only then — Attributes.MINING_EFFICIENCY is added, which is where
Enchantments.EFFICIENCY lands, at level² + 1. Haste and conduit power are
read together through MobEffectUtil.hasDigSpeed and
MobEffectUtil.getDigSpeedAmplification, which returns the greater of the
two amplifiers — a beacon and a conduit are interchangeable and do not stack —
and multiply by 1 + 0.2 × (amplifier + 1). Mining fatigue is
not an attribute at all but a literal power of 0.3, with the amplifier plus one
as the exponent — 0.3, 0.09, 0.027 and on down for anything higher. Then
Attributes.BLOCK_BREAK_SPEED, then Attributes.SUBMERGED_MINING_SPEED (0.2
by default) if the eyes are in FluidTags.WATER, and finally divide by five
if the player is not on the ground.
For stone at hardness 1.5 and an iron pickaxe at 6.0, that is 6 ÷ 1.5 ÷ 30 = 0.133 per tick, so the eighth tick is the one that passes 1.0.
Why the two answers match
The two clocks count differently and still land on the same number, and the
reason is a matter of when the server is asked rather than what it computes.
The client accumulates: MultiPlayerGameMode.continueDestroyBlock adds one
tick’s fraction each time it runs, and the first of those runs in the same
client tick as the Minecraft.startAttack that opened the dig through
MultiPlayerGameMode.startDestroyBlock, so after k
client ticks the client holds k fractions. The server keeps no accumulator.
For a live dig, ServerPlayerGameMode.incrementDestroyProgress multiplies the
per-tick fraction by elapsed ticks plus one, and that plus one is exactly the
client tick that both started and continued.
Now the timing. ServerPlayerGameMode.tick increments its own tick counter
before recomputing, so the number it reaches inside the level tick is one
fraction ahead of the client’s — and it uses that number only for the crack
stage other players see, because the live branch throws the value away and
only the delayed branch compares a value with 1.0. What the live branch sends every tick is a level event with no source
entity, so the breaker is sent it too: LevelEvent.PARTICLES_DESTROY_PROGRESS,
or LevelEvent.PARTICLES_AND_SOUND_DESTROY_PROGRESS on every fourth, naming
the face being dug and not the progress. The comparison that decides anything
happens somewhere else: the STOP is handled in the packet drain, before that
tick’s increment (the server
tick), so
ServerPlayerGameMode.handleBlockBreakAction measures elapsed ticks over the
same span the client counted and arrives at the same 1.067. The agreement is
not a coincidence in the arithmetic; it is the packet drain running before the
levels do.
Recomputing rather than accumulating has a second consequence: lose haste
mid-dig and the server rescales the whole dig retroactively, while the client
keeps the progress it already banked. Swapping tools is different: the client
sees a new stack in MultiPlayerGameMode.sameDestroyTarget, sends an ABORT and
a START, and both clocks begin again.
They agree without talking because almost every input is either static data
both sides loaded — hardness, the block tags, the Tool component travelling
with the stack — or a syncable attribute, or a synced effect. The inputs that
could drift are the ones the client reports rather than shares, where the
player stands and, sharpest of all, which slot is selected:
MultiPlayerGameMode.ensureHasSentCarriedItem runs at the top of every
MultiPlayerGameMode.continueDestroyBlock to send a
ServerboundSetCarriedItemPacket the moment it changes.
The button is not the switch
Seventy per cent of the server’s own clock is what a STOP must have run
before the block breaks immediately (ServerPlayerGameMode.handleBlockBreakAction).
For stone that is about two ticks of slack. Below the bar the STOP is not
refused: unless a delayed dig is already pending, when it does nothing at all,
the handler sets ServerPlayerGameMode.hasDelayedDestroy, copies the
position and the original start tick into
ServerPlayerGameMode.delayedDestroyPos and
ServerPlayerGameMode.delayedTickStart, and leaves the rest to the delayed
branch of ServerPlayerGameMode.tick. The sequence is acknowledged regardless
— ServerGamePacketListenerImpl.handlePlayerAction calls
ServerGamePacketListenerImpl.ackBlockChangesUpTo for all four break actions,
unconditionally, after the game mode has run. The delayed branch does not count
the ticks since the dig began: it multiplies the per-tick fraction by the start
tick itself, plus one, and that tick is the game mode’s count since the player
joined or respawned, so the answer does not grow with waiting. For any dig
begun more ticks after that than it takes, the product clears 1.0 on the
branch’s first run, later in the same server tick, and
ServerPlayerGameMode.destroyBlock runs — after the level has broadcast that
tick’s block changes. So the receipt still arrives with no correction in front
of it, the client settles prediction M against the stone it recorded, and
ClientLevel.syncBlockState puts the stone back. The block is visibly there
again for a tick, until the next broadcast carries the air as an ordinary block
update. The prediction was not wrong — it was undone and then redone.
Letting go changes nothing: a client that has sent its STOP is no longer
digging, so releasing the button sends nothing, and an ABORT from a later dig clears
ServerPlayerGameMode.isDestroyingBlock and erases the crack without touching
ServerPlayerGameMode.hasDelayedDestroy — and ServerPlayerGameMode.tick
tests the delayed dig first, before the live one. A delayed dig begun too
soon after the player joined or respawned never clears on its own, and then
starting a dig on a different block does not help either: the START is
processed normally, but the delayed branch keeps winning the tick. The delayed
path re-checks almost nothing on its way through — not reach, not
MinecraftServer.isUnderSpawnProtection, not ServerLevel.mayInteract, not
that the player is still in the same room. It calls
ServerPlayerGameMode.destroyBlock directly rather than
ServerPlayerGameMode.destroyAndAck, so two of the three ways it can fail there
are silent, with no corrective block update. Inside the tick it ends only when
the block turns to air, the one condition ServerPlayerGameMode.tick tests
before recomputing progress, or when that recomputation, made with whatever the
player now holds and has, reaches 1.0; otherwise it lasts until the player
leaves or respawns.
What a real refusal looks like
The refusals that are refusals differ in what they send back, and the
difference is observable. A failed Player.isWithinBlockInteractionRange
check — which allows a full block of slack — sends nothing but the receipt, and it
guards STOP as well as START; only an abort gets past it, and only while a dig
is live, so an abort from too far away still ends one. Being above
LevelHeightAccessor.getMaxY, failing
ServerLevel.mayInteract or failing Player.blockActionRestricted each answer
with a ClientboundBlockUpdatePacket carrying the true state. Spawn protection
answers with an overlay message from ServerPlayer.sendSpawnProtectionMessage
and no block update whatsoever. Every one of those exits is named in a string
behind SharedConstants.DEBUG_BLOCK_BREAK, which is the best map of this state
machine there is.
Worth holding the two click lectures side by side here, because the same two
gates answer oppositely. Breaking above the build height sends the true state
back; placing above it sends an action-bar line and no block update at all.
Breaking inside spawn protection sends the message and no block update;
placing there sends the message and both updates (block
interaction).
The rule is not the gate but the pipeline. A placement’s two corrective updates
sit in one branch below the build-height test and go out for every outcome that
reaches it, refusal or not. A break has no such branch: each of its five exits
decides for itself. Three send the true state back — the build height,
ServerLevel.mayInteract and Player.blockActionRestricted. Spawn protection
sends its message and no state. And being out of reach sends nothing but the
receipt, which puts the block back like any other; what a STOP refused for
reach leaves wrong is the server, whose live dig goes on sending the breaker its
level events until a START or ABORT it accepts, or until the block turns to air.
Speed and drops are two scans of one list
DataComponents.TOOL holds a Tool: a list of Tool.Rule, a
Tool.defaultMiningSpeed, a Tool.damagePerBlock and a
Tool.canDestroyBlocksInCreative. Each rule names a set of blocks and carries
an optional speed and an optional drop verdict, and the game builds its
rules in three shapes, each with a factory that says so:
Tool.Rule.minesAndDrops carries both, Tool.Rule.deniesDrops carries only
the verdict and Tool.Rule.overrideSpeed only the speed.
Tool.getMiningSpeed and
Tool.isCorrectForDrops are two independent walks of the same list, each
taking the first rule that both matches the block and carries the field it
came for. ToolMaterial.applyToolProperties builds every pickaxe, axe, shovel
and hoe from exactly two rules, in this order:
| the iron pickaxe’s rules, in order | what Tool.getMiningSpeed does | what Tool.isCorrectForDrops does |
|---|---|---|
Tool.Rule.deniesDrops on BlockTags.INCORRECT_FOR_IRON_TOOL — no speed field | skips it | obsidian matches, answers no |
Tool.Rule.minesAndDrops on BlockTags.MINEABLE_WITH_PICKAXE at 6.0 | obsidian matches, answers 6.0 | never reached |
| nothing matched | Tool.defaultMiningSpeed, 1.0 | false |
That is why an iron pickaxe mines obsidian and drops nothing. The speed scan
falls through the deny rule — it has no speed — and takes the full pickaxe
6.0; the drop scan stops at the deny. The block still takes forever, but for a
different reason: Player.hasCorrectToolForDrops is false, so
BlockBehaviour.getDestroyProgress divides by 100 instead of 30. No item in
the game uses all three shapes at once, and the two that never share a Tool
are Tool.Rule.deniesDrops and Tool.Rule.overrideSpeed: the four mining
tools pair a deny rule with a minesAndDrops rule, while shears and the sword
— which deny nothing — are the only users of Tool.Rule.overrideSpeed. The
sword is also one of exactly three whose Tool.canDestroyBlocksInCreative is
false; the other two are the mace and the trident.
The cracks belong to everyone but you
ServerLevel.destroyBlockProgress sends a ClientboundBlockDestructionPacket
to every player in the level within 32 blocks whose entity id is not the
breaker’s. You are never sent your own cracks. What you see is
MultiPlayerGameMode writing straight into ClientLevel.destroyBlockProgress
each tick — the same method the packet handler calls, reached by a different
road. The same asymmetry runs through the break itself: Block.playerWillDestroy
posts level event 2001, which ServerLevel.levelEvent broadcasts within 64
blocks excluding the breaker, because the breaker already played it locally
inside MultiPlayerGameMode.destroyBlock.
BlockDestructionProgress is a plain holder — id, position, progress, a last
touched tick — and BlockDestructionProgress.setProgress clamps only the top,
at 10. The 0–9 window everybody quotes is enforced by the caller:
ClientLevel.destroyBlockProgress stores a stage only for values in [0, 10),
and reads anything else — including the −1 that
MultiPlayerGameMode.getDestroyStage returns at zero progress — as an
instruction to remove that breaker’s entry. Entries are indexed twice, by
breaker id and by position, the latter into a sorted set so the deepest crack
at a position wins; LevelExtractor collects those within 32 blocks of the
camera each frame. Entries untouched for 400 ticks are swept every twentieth
tick, which is what eventually clears the cracks left by someone who
disconnected mid-dig.
Remove, damage, roll, drop
ServerPlayerGameMode.destroyBlock runs a short gauntlet before it writes
anything: ItemStack.canDestroyBlock, then GameMasterBlock against
Player.canUseGameMasterBlocks, then Player.blockActionRestricted. It
captures the BlockEntity first, because the write is about to destroy it.
Then Block.playerWillDestroy — particles and sound to everyone else, piglins
angered for BlockTags.GUARDED_BY_PIGLINS, and a GameEvent.BLOCK_DESTROY
posted for sculk, and delivered before this method returns (the broadcast is a
nested loop).
The write itself is Level.removeBlock, not Level.destroyBlock. It puts the
fluid that was in the block back — water for a waterlogged block, air here —
under flags 3, and everything that follows from those flags is the pair of flowcharts on blocks and
states.
Drops come last and in a fixed order. If Player.preventsBlockDrops (creative)
the method returns here. Otherwise the tool is copied,
Player.hasCorrectToolForDrops is asked once and remembered, and
ItemStack.mineBlock is called unconditionally — though what it does is
conditional four ways over: a DataComponents.TOOL at all, the server side, a
non-zero hardness and a damage-per-block above zero. Not one of those four is
the drop verdict, which is the whole point and belongs to items and
stacks.
Only then, and only if the
write succeeded and the remembered answer was yes, does Block.playerDestroy
run: Stats.BLOCK_MINED, 0.005 of food exhaustion, and Block.dropResources.
The loot side is thin. Block.getDrops supplies LootContextParams.ORIGIN at
the block centre, LootContextParams.TOOL and LootContextParams.THIS_ENTITY,
BlockBehaviour.getDrops adds LootContextParams.BLOCK_STATE, and the set is
LootContextParamSets.BLOCK. The table key is not looked up by name at break
time: BlockBehaviour.Properties.effectiveDrops resolves the block’s id under
blocks/ once, when the block is constructed. What blocks/stone then does is
two lines of JSON — a silk-touch alternative, else cobblestone if it survives
an explosion — rolled from a seeded per-table sequence rather than the level
random (loot tables). Each surviving stack goes to
Block.popResource, which respects GameRules.BLOCK_DROPS, jitters the
position ±0.25 on all three axes around the block centre, gives the
ItemEntity its small upward kick in the constructor and a ten-tick pickup
delay. Ores add their experience afterwards, in
BlockBehaviour.BlockStateBase.spawnAfterBreak.
Questions players ask
Why does my pickaxe lose durability on obsidian, which drops nothing, but
not on short grass, which does? Durability is spent by ItemStack.mineBlock,
which runs before the drop verdict is consulted and does not care about it —
what it cares about is that the block’s hardness is non-zero and the tool has a
damage-per-block above zero. Obsidian is hard and drops nothing: you pay. Short
grass is Blocks.SHORT_GRASS, hardness zero: you never pay, whatever it drops.
Shears are the exception at both ends — ShearsItem is the only override of
Item.mineBlock in the game, and it tests neither hardness nor drops: the one
thing it asks of the block is that it is not in BlockTags.FIRE. Shearing
grass costs a point.
Why can’t I break blocks with a sword in creative? Because
Tool.canDestroyBlocksInCreative is false on the sword’s component and
ItemStack.canDestroyBlock checks it on both sides. It is a property of the
item, not a special case in the game mode — which is why the client refuses
first, and why the correcting block update the server sends back is a no-op:
the client predicted nothing to correct.
For a 1.21-era reader. ServerboundSwingPacket is gone; on a dig,
ServerboundPunchPacketdoes its work, and it names no hand.
Where to look
The two clocks are the thing to read first, and they are three methods:
BlockBehaviour.getDestroyProgress is the shared formula,
MultiPlayerGameMode.continueDestroyBlock is the client’s accumulator, and
ServerPlayerGameMode.incrementDestroyProgress is the server’s recomputation.
ServerPlayerGameMode.handleBlockBreakAction is the state machine behind all
four break actions and the 0.7 bar, with ServerPlayerGameMode.tick for the
delayed destroy it can leave behind. Then the ends: Minecraft.startAttack
and Minecraft.continueAttack for where a dig begins each tick,
ServerPlayerGameMode.destroyBlock for the gauntlet, and
Block.playerWillDestroy with Block.playerDestroy and Block.popResource
for what a broken block leaves behind. Tool.getMiningSpeed and
Tool.isCorrectForDrops are the two walks of one rule list.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Block entities
Verified against Minecraft 26.3 · Part V · A furnace smelts raw iron while nobody is watching, and the player who opens it learns the fire from a block state and the arrow from a menu — never from the block entity itself.
You drop raw iron in the top slot of a furnace, coal in the bottom, and walk
away. Two hundred ticks later there is an iron ingot in a box nobody is
looking at. A block state is one of a fixed table, shared by every position
that has it, so the moment a position needs something of its own — an
inventory, a timer, a name — it gets a BlockEntity: a plain object owned by
its chunk, keyed by its position, created by the block and destroyed with it.
The surprise is how little that object says for itself. A furnace tells
nobody anything: BlockEntity.getUpdatePacket returns null for it,
BlockEntity.setChanged sends nothing at all, and the two things a player
does see — the fire in the world and the arrow in the GUI — are a block
state and four ints from a menu, both of which arrive on the tick after
the smelting step that produced them, because block entities tick in the
level’s last content phase, after the broadcast and the players’ ticks have gone by (the
level tick).
The cast
| class | what it decides | thread |
|---|---|---|
BlockEntity | the position, the cached state, the components, and four defaults every subclass inherits: two for disk, and two for the network that both amount to say nothing | whichever thread owns the level |
BlockEntityType | which blocks the entity is legal on, and what constructs it | immutable once the registry is built |
EntityBlock | whether a block has an entity at all, which one, and which ticker per level | — |
LevelChunk | the position-to-entity map, the ticker wrapper per position, and create / keep / replace / remove on every write | the chunk’s owning thread |
Level | the flat list of tickers and the two gates over it | Server thread, or the Render thread |
AbstractFurnaceBlockEntity | three slots, four ints, a cached recipe check, and when the block’s lit state has to change | Server thread only — its ticker is null on the client |
ChunkHolder | which positions changed since the last drain, and the call to BlockEntity.getUpdatePacket for each broadcast position that has a block entity | Server thread, chunk-source phase |
FurnaceMenu | what an open screen is allowed to see of all that: three slots and four ints | Server thread, mirrored on the client |
A furnace tells nobody anything
BlockEntity has four hooks a subclass is expected to fill in, and they come
in two pairs. BlockEntity.saveAdditional and BlockEntity.loadAdditional are
the disk pair, and the base class’s versions do nothing because what it saves of its own, the id, the position and the components, is written by the shells around them. BlockEntity.getUpdateTag and
BlockEntity.getUpdatePacket are the network pair, and their emptiness is a
decision rather than an absence: the tag comes back empty and the packet comes
back null. The base class declines to be synced, and a subclass that wants
to be must say so.
Nineteen classes say so, and every one of them answers with
ClientboundBlockEntityDataPacket.create of itself: signs, banners, beacons,
skulls, spawners and trial spawners, conduits, end gateways, structure and
jigsaw blocks, campfires, decorated pots, vaults, shelves, brushable blocks,
creaking hearts, copper golem statues and the two test blocks. Counting those
declarations in the decompile and mapping each class onto the 49
registrations in BlockEntityTypes gives twenty synced types out of
forty-nine, because HangingSignBlockEntity is a type of its own that
inherits SignBlockEntity’s override and adds nothing.
The overriders of the packet and the overriders of the tag are not the same
list — there are nineteen of those too, and it is a different nineteen — and the
two classes that differ are instructive.
PistonMovingBlockEntity overrides BlockEntity.getUpdateTag but not the
packet, so its state travels only in a chunk send
(pistons and block events).
CopperGolemStatueBlockEntity overrides the packet but not the tag, so what
it broadcasts is the base class’s empty tag.
Everything else a client knows about a block entity (an operator’s query, a command block’s screen and the debug channel aside) it knows by consequence: the block state it can see, a menu it has been given, and a block event — the third channel, and the one that swings a chest lid without either side saying what is inside (pistons and block events). The trace below is what the first two cost.
One save hook, four ways out
Saving is a tree, not a chain, and the branch a caller picks decides how much
metadata rides along. Only BlockEntity.saveAdditional belongs to the
subclass; everything else is bookkeeping the base class adds.
| what runs | what it writes | who calls it |
|---|---|---|
BlockEntity.saveAdditional | the subclass’s own fields, and nothing else | nobody directly |
BlockEntity.saveCustomOnly | that alone | thirteen of the tag overrides, the pick-block path, /clone and a few others |
BlockEntity.saveWithoutMetadata | that plus components | the two below, plus BlockInput, the falling block and a few others |
BlockEntity.saveWithId | that plus id | a structure template, which records a relative position of its own, and the adventure-mode block check’s cache |
BlockEntity.saveWithFullMetadata | that plus id, x, y and z | LevelChunk.getBlockEntityNbtForSaving, /data, the block predicates, a text component’s block NBT and a few others |
Reading back is BlockEntity.loadWithComponents (fields plus components) or
BlockEntity.loadCustomOnly (fields only) over a ValueInput
(codecs, NBT and JSON) — but something
has to decide which class to construct first, and that cannot come through
a ValueInput, because no entity exists yet to own one. So
BlockEntity.loadStatic reads id off the raw CompoundTag with
BlockEntity.TYPE_CODEC, calls BlockEntityType.create, and only then wraps
the same tag in a ValueInput and loads it. Any of those three steps failing
logs and returns null, and the position has no entity until a plain read builds
a blank one from the block state.
The network joins that path at the end rather than reusing it whole:
ClientPacketListener.handleBlockEntityData never reads id — it asks for the
entity by position and type, which a plain read builds from the block state if
it is missing, and hands the tag to BlockEntity.loadWithComponents. There is no separate network
deserialiser. Where the chunk’s block_entities list is
written and read is chunk storage.
Create, keep, replace, remove
A block entity that appears because a block appeared is created and
destroyed inside one method: LevelChunk.setBlockState, after the section
write, the heightmaps and the light checks (what a write
does). That is the lifecycle
path, and it makes two decisions — but in the opposite order to the one this
section is named for, because the old entity has to go before
BlockBehaviour.BlockStateBase.onPlace runs and the new one is built after it.
Removal first, then creation. It is not the only way one
comes into being: a chunk arriving from disk builds its entities from saved
tags, one arriving from the network from its block states and then their tags,
and LevelChunk.getBlockEntity in its immediate
mode constructs a missing one on a plain read — which is the mode every
Level.getBlockEntity asks for.
Removal happens only when the block changed, the old state had an
entity, and the new state does not claim it through
BlockBehaviour.BlockStateBase.shouldChangedStateKeepBlockEntity — which
exactly two blocks in 26.3 override, CopperChestBlock and
CopperGolemStatueBlock, both keeping the entity when the old state was
another block of the same family, so oxidising or waxing a copper chest does
not empty it. Removal is two halves with different gates. The side effects —
BlockEntity.preRemoveSideEffects, which for anything implementing
Container drops the contents through Containers.dropContents — run only
on the server and only with Block.UPDATE_SKIP_BLOCK_ENTITY_SIDEEFFECTS
clear. The bookkeeping, LevelChunk.removeBlockEntity, runs regardless —
though only the last of its steps is itself unconditional. The map entry going,
the game-event listener being unregistered, the debug view being told and the
entity being flagged removed all sit behind is this chunk loaded in a level,
which on the server a LevelChunk is not before its promotion finishes or once it is
unloading; the rebind of the ticker to LevelChunk.NULL_TICKER happens either
way.
Creation happens after BlockBehaviour.BlockStateBase.onPlace, and only
if the new state has a block entity and BlockBehaviour.BlockStateBase.onPlace
has not replaced the block. The chunk looks for
an existing one without creating it, and if what it finds does not pass
BlockEntity.isValidBlockState for the new state it logs a mismatched block
entity warning, removes it and builds a fresh one from
EntityBlock.newBlockEntity — the block’s own factory, not
BlockEntityType.create. Only a surviving match is kept, with its cached
state refreshed and LevelChunk.updateBlockEntityTicker re-asking the block
for a ticker. That is why flipping a furnace’s lit property costs almost
nothing: same block, valid state, same object — a fresh
LevelChunk.BoundTickingBlockEntity rebound into the wrapper is the whole of
the expense.
Chunk load and unload use the ends of the same machinery.
ChunkStatusTasks.full runs LevelChunk.runPostLoad to turn the saved tags
into entities, then LevelChunk.setLoaded and
LevelChunk.registerAllBlockEntitiesAfterLevelLoad, which attaches listeners
and tickers to entities built before the chunk belonged to a level.
ServerLevel.unload calls LevelChunk.clearAllBlockEntities: every entity
flagged removed, every ticker pointed at LevelChunk.NULL_TICKER.
Two hundred ticks nobody watches
sequenceDiagram
participant SL as ServerLevel
participant LC as LevelChunk
participant AFBE as AbstractFurnace<br/>BlockEntity
participant CH as ChunkHolder
participant FM as FurnaceMenu
participant CPL as ClientPacketListener
Note over SL,CPL: tick N, chunk-source phase: the broadcast drain runs now, with nothing yet to send
Note over SL,CPL: tick N, entities phase: the players' menus, with nothing new yet
Note over SL,CPL: tick N, block-entities phase, the level's last content phase
SL->>LC: LevelChunk.<br/>BoundTickingBlockEntity.tick
LC->>AFBE: serverTick, quickCheck finds the smelting recipe
Note over AFBE: fuel consumed, both lit fields take 1600, the cooking timer at one
AFBE->>SL: setBlock, LIT true under flags 3
SL->>LC: setBlockState, same block, the entity kept and the ticker rebound
SL->>CH: blockChanged, which only marks the holder dirty
AFBE->>SL: updateNeighbourForOutputSignal, the comparator poke
Note over SL,CPL: tick N plus 1, chunk-source phase, the broadcast drain
CH->>CPL: ClientboundBlockUpdatePacket, the fire appears
Note over CH: BlockEntity.<br/>getUpdatePacket<br/>answers nothing
Note over SL,CPL: tick N plus 1, entities phase, the players tick
Note over CH,FM: AbstractContainerMenu.<br/>broadcastChanges checks the four data slots
FM->>CPL: ClientboundContainerSet<br/>DataPacket per changed slot
Two consequences of one smelting tick, both a tick late because the block entity ticks in the level’s last content phase, after the drain that turns a dirty holder into a packet and after the players whose ticks reconcile the menu. The two routes out are the block state and the menu’s four ints, and they arrive by different machinery.
The furnace’s ticker is handed out by AbstractFurnaceBlock.createFurnaceTicker
only when the level is a ServerLevel — on the client it is null, so no
furnace anywhere ever ticks there. AbstractFurnaceBlockEntity.serverTick is
therefore the whole of smelting: burn down
AbstractFurnaceBlockEntity.litTimeRemaining, ask
AbstractFurnaceBlockEntity.quickCheck (a RecipeManager.CachedCheck, which
retries last tick’s match before scanning the type) for a recipe on the input
slot, check that the result slot can take the output, and, if the fire is out
but fuel is present, light it: both lit fields take
AbstractFurnaceBlockEntity.getBurnDuration for that item — what its
DataComponents.COOKING_FUEL component resolves to in this furnace, 1600 for
coal — and one fuel item is consumed. Then
AbstractFurnaceBlockEntity.cookingTimer advances by one. Where the recipe
comes from is recipes.
The two hundred is the recipe’s number rather than the furnace’s:
AbstractFurnaceBlockEntity.cookingTotalTime holds the recipe’s cookingtime
— 200 in raw iron’s recipe, a field every cooking recipe must name —
divided by the speed multiplier the fuel sets, which is one in a furnace. Nor is the timer a stopwatch that
merely stops. With the fire out and either slot empty it runs backwards, two a
tick, clamped at zero, so an interrupted smelt loses its progress in half the
time it took to earn it.
Two writes leave the block entity, and neither leaves the server this tick.
The first is the fire: lit-ness is a block state, so the ticker calls
Level.setBlock on its own position with AbstractFurnaceBlock.LIT flipped.
ServerChunkCache.blockChanged marks the holder dirty — and the drain that
turns dirty holders into packets, ServerChunkCache.broadcastChangedChunks,
lives in the chunk-source phase, which ran before entities and long before
block entities. The second is BlockEntity.setChanged, which the tick calls
only when it lit, finished or went out, as it did here: it marks the chunk
unsaved and pokes comparators through
Level.updateNeighbourForOutputSignal — the unconditional half of that walk,
and the reason a comparator notices a hopper filling a chest (one int, and the
fan-out that exists to deliver
it)
— and that is all it does. It sends nothing.
So a viewer sees both a tick late, by two different routes. Next tick’s drain
sends the ClientboundBlockUpdatePacket and then — for every broadcast
position whose state has a block entity, including each position inside a
ClientboundSectionBlocksUpdatePacket — calls BlockEntity.getUpdatePacket,
one of the game’s two call sites (the other resends the blocks under a player
the server thinks is standing on air), and gets null from the furnace. Next tick’s
entity phase runs ServerPlayer.tick, which runs
AbstractContainerMenu.broadcastChanges, which compares the menu’s four data
slots against the values last sent and emits a
ClientboundContainerSetDataPacket per difference. Those four ints are the
furnace’s entire GUI: the flame is data 0 over data 1 and the arrow data 2
over data 3, read by AbstractFurnaceMenu.getLitProgress and
AbstractFurnaceMenu.getBurnProgress. The tick that lights the fire moves
three of them, because data 1 is the fuel’s total burn duration; after that
only 0 and 2 change, so a steady smelt is two packets a tick per open screen
and none at all with no viewer.
How a menu is opened, synchronised and closed is
containers and menus.
On the fire’s two-hundredth tick, AbstractFurnaceBlockEntity.burn moves the ingot into the result
slot and AbstractFurnaceBlockEntity.setRecipeUsed adds one to a counter map
— not to a recipe object, which is why
AbstractFurnaceBlockEntity.getRecipeUsed returns null. The experience is
paid out on collection: FurnaceResultSlot.checkTakeAchievements, reached
from a take or a shift-click, calls
AbstractFurnaceBlockEntity.awardUsedRecipesAndPopExperience, which pops the
orbs at the player and unlocks the recipes.
Loaded is not enough to tick
Three things about that list are worth separating: the gates over it, the way it maintains itself, and the fact that what a ticker does between calls is nobody’s business but its own.
The two gates, and the distance that is not view distance
Level.tickBlockEntities walks one flat list, Level.blockEntityTickers,
under two gates the tickers themselves never see.
TickRateManager.runsNormally is the first, so /tick freeze stops every
block entity in the game. Level.shouldTickBlocksAt is the second, and it is
where the interesting asymmetry lives: on Level it is always true, on
ServerLevel it is DistanceManager.inBlockTickingRange — the
simulation chunk tracker, not the loading one (tickets and
loading). A chunk your view distance
keeps loaded and your simulation distance does not reach holds furnaces that
do not smelt, and nothing about the block entity records this: it is simply
never called. Below the gates,
LevelChunk.BoundTickingBlockEntity adds its own — not removed, adopted by a
level, inside the world border, and then, on a ServerLevel only, the chunk at
FullChunkStatus.BLOCK_TICKING with its entities loaded — before re-reading
the live state and ticking only while BlockEntityType.isValid still holds,
logging once and skipping while it does not.
The list that prunes itself
The list is never searched. Removal rebinds the chunk’s
LevelChunk.RebindableTickingBlockEntityWrapper to LevelChunk.NULL_TICKER,
whose TickingBlockEntity.isRemoved is permanently true, and the next pass of
Level.tickBlockEntities drops it on the way past — no search, on a list that
is walked every tick anyway. Additions made during the walk go to
Level.pendingBlockEntityTickers and are folded in at the top of the next
pass, so a block entity created by another block entity’s tick starts ticking
one tick later. The client runs the same method from Minecraft.tick, after
its entity pass and before ClientLevel.tick, and only while unpaused.
Three cadences the gates know nothing about
A ticker’s cadence is its own business, and the hopper is the first worth
knowing: HopperBlockEntity moves one item and then sets a cooldown of eight of
its own ticks, the number HopperBlockEntity.MOVE_ITEM_SPEED names, so a hopper
chain runs at two and a half items a second at twenty ticks a second. The two
spawners are the other two cadences worth knowing, and they are the block
entities that keep their whole personality outside themselves.
SpawnerBlockEntity holds an anonymous BaseSpawner and hands it the tick;
the delay, the spawn count, the required player range and the rolled
SpawnData all live on the BaseSpawner, which is not a BlockEntity at all.
Both sides tick it, and they run different methods: BaseSpawner.serverTick
does the spawning, and BaseSpawner.clientTick runs its own copy of the
countdown to spin the cube and drop smoke and flame — a local animation, never
a packet, and both are gated on BaseSpawner.isNearPlayer, so a spawner with
nobody in range neither spins nor fires. TrialSpawnerBlockEntity is the same
arrangement with a state machine on top: its TrialSpawner holds a
TrialSpawnerStateData, the TrialSpawnerState it moves through is a property
of the block state, and the block entity holds the TrialSpawner (entity
lifecycle owns what either
one spawns and under which reason). Every other block entity in
the sub-package is one of the shapes on this page — four hooks, a ticker handed
out per level, the four save shells, and the create-keep-replace-remove
lifecycle — with different fields in the middle.
Questions players ask
Why doesn’t the client know what is in a chest until I open it?
ChestBlockEntity overrides neither sync hook, so a chunk send carries its
type and position with no tag at all (an empty update tag is sent as none)
and the client builds its chest from the block state the packet’s sections
already decoded, with an empty container inside. What ticks on the client is
animation only: ChestBlock.getTicker hands out
ChestBlockEntity.lidAnimateTick on the client and null on the server, the
exact mirror of the furnace.
Why does a shulker box keep its contents when every other container drops
them? Because dropping is the base class’s behaviour, not the block’s:
BlockEntity.preRemoveSideEffects drops the contents of anything that
implements Container. Eight classes override that hook, and
ShulkerBoxBlockEntity is the one that overrides it to do nothing whatever.
The furnace overrides it too and calls the base version first, so it drops
like everything else and then pops the experience owed for uncollected smelts
at the block — awarding the recipes to nobody.
Why does the arrow only move when the screen is open? Because the arrow
is not a property of the furnace. It is data slot 2 of an
AbstractContainerMenu that exists only while a player has that screen open,
reconciled once per tick by the player who owns it. Close the screen and the
menu is gone, and the furnace goes on smelting with no packets at all.
Where to look
Start with the two hooks that decide everything else:
BlockEntity.getUpdatePacket and BlockEntity.setChanged, neither of which
sends anything for a furnace. BlockEntity.loadStatic is the one place a class
is chosen from a tag, and BlockEntity.preRemoveSideEffects is the last word
before one is unregistered. The lifecycle is
LevelChunk.setBlockState with LevelChunk.updateBlockEntityTicker and
LevelChunk.NULL_TICKER, and the ticking is Level.tickBlockEntities over
Level.shouldTickBlocksAt. Then AbstractFurnaceBlockEntity.serverTick for
the smelt itself and ChunkHolder.broadcastBlockEntity for the call site that asks a block entity for a packet whenever its position is broadcast. EntityBlock.getTicker and
BaseEntityBlock.createTickerHelper are not named above and are the door into
how any block hands out a ticker at all.
How a block entity is drawn — and why a chest’s block model is empty — is block-entity rendering, in Part XI.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Signal and dust
Verified against Minecraft 26.3 · Part V · A lever on the floor is flipped on and then off again, and two redstone dust to the east of it go to 15 and 14 — and then count their way back down.
You flip a lever, and the dust beside it turns bright. Flip it back and the
line goes dark — in one tick, but not in one pass. Each wire recomputes its
own strength from scratch, writes it, and then hand-issues seven
Level.updateNeighborsAt calls: its own position and all six neighbours. Each
of those seven visits six neighbours of its own, so one wire whose power
changed costs forty-two neighbour updates
(DefaultRedstoneWireEvaluator.updatePowerStrength). And because the recursion
stops on value rather than on distance, the wire at the far
end is reached once for every intermediate value the near end passes through
on the way down. That staircase is the whole cost of redstone dust, and nobody
has ever seen it: the cascade finishes inside one packet handler, and the
packet a client is sent is built later in the same tick from whatever the
position holds by then. The game ships a second implementation of exactly this
computation, behind a feature flag, which walks the whole connected network in
two ordered phases and does not produce the staircase at all.
The cast
| class | what it decides | thread |
|---|---|---|
SignalGetter | every question about power: what a position emits, what reaches it, and the direction order the answers are gathered in | a Level interface, either side |
BlockBehaviour.BlockStateBase | the three power answers this trace asks a state for — is it a source, what is its weak signal per face, what is its strong signal — beside whether it conducts and whether a wire connects to it. The analog pair is the comparator’s | either side |
LeverBlock | the trace’s source: 15 in every direction, and 15 strongly into one block only | server — the client’s copy writes nothing |
RedstoneWireBlock | which sides a wire connects to, what it emits through them, and the mutable flag that stops it counting itself | server for power; its connections on both sides |
RedstoneWireEvaluator | minus one per block: what the neighbouring wires are worth to this one | server |
DefaultRedstoneWireEvaluator | one wire at a time, recursively, with the fan-out issued by hand | server |
ExperimentalRedstoneWireEvaluator | every wire the change reaches, at once, in two phases, everything off before anything on, allocated fresh per call | server |
Orientation | in the experimental mode only, where an update came from, so the fan-out can be ordered relative to it | server |
What one neighbour update to a wire costs
Redstone dust has no scheduler and no graph. It is what happens when blocks answer
two questions about their neighbours — how much signal do you give me and
did something near you change — through the same BlockBehaviour.neighborChanged
fan-out every neighbour update uses. This is the whole of the default
implementation, from one update arriving to the next batch leaving.
flowchart TD
IN["RedstoneWireBlock.neighborChanged"]
CLIENT{"is this a ClientLevel"}
NOTHING["nothing at all"]
CALC["DefaultRedstoneWireEvaluator.calculateTargetStrength"]
BLK["RedstoneWireBlock.getBlockSignal, with RedstoneWireBlock.shouldSignal off"]
WIRE["RedstoneWireEvaluator.getIncomingWireSignal"]
TARGET["the target: the block signal, or the larger of the two"]
SAME{"is the target the POWER already stored"}
STOP["no write, no fan-out: the cascade ends here"]
WRITE["Level.setBlock under Block.UPDATE_CLIENTS alone"]
FAN["seven Level.updateNeighborsAt calls: this position and its six neighbours"]
OUT["forty-two neighbour updates, queued a layer at a time"]
IN --> CLIENT
CLIENT -- "yes" --> NOTHING
CLIENT -- "no" --> CALC
CALC --> BLK
BLK -- "under 15" --> WIRE --> TARGET
BLK -- "15, so the wires are never asked" --> TARGET
TARGET --> SAME
SAME -- "yes" --> STOP
SAME -- "no" --> WRITE --> FAN --> OUT
OUT -- "every wire among them, again" --> IN
One neighbour update to one wire, and the loop at the bottom is the page:
forty-two updates leave, some of them land on wires, and each of those
re-enters at the top. The cascade ends only where a recomputed target equals
the RedstoneWireBlock.POWER already stored, which is a test on the value and not on the
distance.
The first branch is belt and braces. RedstoneWireBlock.neighborChanged opens
with a not-client test, and it would never be reached on a ClientLevel
anyway: Level.updateNeighborsAt and Level.neighborChanged are empty methods
there, so nothing on the client ever dispatches a neighbour update to a block
at all (blocks and states).
The two facts that make the staircase are both in that figure. The write uses
Block.UPDATE_CLIENTS alone — flag 2, the broadcast bit and nothing else
(block update flags) — so the fan-out is not the one
Level.setBlock would have done: it is issued afterwards, by hand, over
seven positions rather than one. And the recursion terminates on value, not
on distance: a wire whose recomputed strength equals what it already holds
writes nothing and tells nobody. A line going dark therefore re-enters every
wire again at every step of the descent, and each visit is the last one only when
the value has stopped moving. None of those intermediate writes is ever sent: Level.sendBlockUpdated
records the position and not the state, and the once-a-tick flush reads the
level back to build the packet (what the client is
told).
What a block answers when it is asked for power
Three questions, all on BlockBehaviour.BlockStateBase, all answered by the
block. BlockBehaviour.BlockStateBase.isSignalSource is whether the block
emits at all. BlockBehaviour.BlockStateBase.getSignal is the weak signal
it offers to a given face. BlockBehaviour.BlockStateBase.getDirectSignal is
the strong one, and the difference between them is entirely a matter of
who is allowed to pass it on. Conduction is
BlockBehaviour.BlockStateBase.isRedstoneConductor, from
BlockBehaviour.Properties.isRedstoneConductor, which defaults to is this
state’s collision shape a full block.
SignalGetter is where the two meet, and the join is easy to get backwards.
SignalGetter.getSignal reads the block’s own weak signal and then, only
if that block is a redstone conductor, takes the larger of it and
SignalGetter.getDirectSignalTo — the strongest signal being pushed into that
position from any of its six neighbours. It is a maximum, not a choice between
two modes: a powered conductor offers the greater of what it emits itself and
what is being forced into it. That single line is what “strongly powered”
means, and it is why a block with a lever on it powers the dust beside it. A
conducting block a wire merely points into is strongly powered too — that is
how a line lights a lamp through the block at its end — but no other dust can
see it, for a reason that
has nothing to do with this line and everything to do with
RedstoneWireBlock.shouldSignal, below.
The lever shows both halves at once. LeverBlock.ownSignal is 15 in every
direction when powered — that is the weak signal, and it is what the dust next
to the lever reads. LeverBlock.getDirectSignal is 15 only into the one block
the lever is attached to. So a conducting block behind a lever is strongly powered,
and everything touching that block sees 15 too.
The other sources answer the same three questions and differ mostly in what
they answer and when they change their mind: ButtonBlock books a scheduled
tick to turn itself off, BasePressurePlateBlock and its two subclasses
re-read what is standing on them, DetectorRailBlock watches for entities and
TripWireHookBlock for its tripwire, DaylightDetectorBlock reads the sky,
and RedstoneTorchBlock and RedstoneWallTorchBlock invert whatever they are
attached to. None of these needs a section of its own, because the contract
above is nearly the whole of what a circuit sees; the repeater and the observer,
which also answer for themselves whether a wire connects, belong to the part’s
last lecture.
One of them keeps state the contract cannot see, and it is the reason a
fast clock burns out. RedstoneTorchBlock.RECENT_TOGGLES is a weak map from
level to a list of toggles; RedstoneTorchBlock.tick prunes anything older than
60 ticks off the front of it, and RedstoneTorchBlock.isToggledTooFrequently
burns the torch out on the eighth surviving entry for that position, to
try again 160 ticks later. Those are the numbers
RedstoneTorchBlock.MAX_RECENT_TOGGLES, RedstoneTorchBlock.RECENT_TOGGLE_TIMER
and RedstoneTorchBlock.RESTART_DELAY name: 8, 60 and 160.
Three direction orders, and only one of them is about reading
Three fixed direction orders run through this page and they are not interchangeable. Two decide who gets told something; the third decides what a block reads, and it is the one this page uses.
| array | order | what it governs |
|---|---|---|
SignalGetter.DIRECTIONS | down, up, north, south, west, east | what a block reads. Only SignalGetter.getBestNeighborSignal walks the array; SignalGetter.getDirectSignalTo and SignalGetter.hasNeighborSignal are written out by hand in the same order |
NeighborUpdater.UPDATE_ORDER | west, east, down, up, north, south | which neighbour is told first about a change, on the neighbour channel |
BlockBehaviour.UPDATE_SHAPE_ORDER | west, east, north, south, down, up | which neighbour is asked first to re-fit, on the shape channel |
Only the first row is about reading, and the three reading
methods in it stop early, not on the same thing: the two that return a number —
SignalGetter.getBestNeighborSignal and SignalGetter.getDirectSignalTo —
stop at a 15, while SignalGetter.hasNeighborSignal, which returns a boolean,
stops at the first answer above zero. That is not a micro-optimisation with no
consequences: a position saturated from one side never reads the sides after it.
The other two rows are orders in which something is told, and a fan-out
never stops early. SignalGetter.DIRECTIONS is plain Direction order, which
is the only reason its first entry is down.
Dust, and how far it reaches
RedstoneWireBlock.POWER is the number, 0 to 15. Four RedstoneSide
properties — one per horizontal, each NONE, SIDE or UP — record how the
wire is drawn and, more
importantly, which sides it talks through. What a wire is worth
to its neighbours is RedstoneWireEvaluator.getIncomingWireSignal, and the
minus one per block everyone knows lives in its last line: it takes the best
of the wires it can see and subtracts one, floored at zero. That subtraction is
the whole of the reach: in a line whose first wire holds 15, the fifteenth holds
1 and the sixteenth 0, which is a dark block rather than a shorter line. The wires it can
see are the four beside it, plus the wire on top of a conducting neighbour
when nothing conducts above this position, plus the wire below a
non-conducting neighbour — which is the power half of “dust climbs a block
and falls down one”. The drawing half is RedstoneWireBlock.getConnectingSide
below, which asks BlockBehaviour.BlockStateBase.isFaceSturdy for the climb
where this one asks about conduction.
Two asymmetries follow from RedstoneWireBlock.getSignal and are worth
stating plainly, because they are the two questions every redstone build
eventually asks. A wire returns zero when the direction asked about is
Direction.DOWN — so dust never powers the block above it. It returns its
full power without any connection test when the direction is Direction.UP —
so dust always powers the block below it. In every other direction it answers
only if its connection on the opposite side is made.
Connection itself is two rules and a completion pass.
RedstoneWireBlock.shouldConnectTo is the real one, and it asks the
neighbour’s own BlockBehaviour.shouldRedstoneWireConnectTo: another wire
always, a RepeaterBlock along its own axis, an ObserverBlock only on its
output side, opposite the one it watches, and by default any block that says it is a signal source — that
last clause only when a direction is supplied, which the vertical rules do not
do, so up and down connect to wire and to nothing else.
RedstoneWireBlock.getConnectingSide adds the vertical cases — up over a
face-sturdy neighbour, down past a non-conducting one. And then
RedstoneWireBlock.getConnectionState runs a completion pass that produces
most of the confusion: if a wire has no north or south connection, west and
east are set anyway, and the same the other way round. That is why a lone
dust is drawn as a cross, and why a wire fed from the west appears to point
firmly into whatever is on its east — a piston, say — which satisfies neither
real rule. The piston is not a source and, by Blocks.pistonProperties, not a
conductor. It gets powered anyway, because the wire’s east side is SIDE by
completion and RedstoneWireBlock.getSignal asks about the side, not about
the neighbour.
RedstoneWireBlock.shouldSignal is the oddest thing on the page: a mutable
boolean on the block singleton, flipped false for the duration of
RedstoneWireBlock.getBlockSignal so that a wire does not count itself or its
neighbouring wires as sources while it works out its block power. It works
because the Server thread is the only writer and never re-enters the method.
It is also the answer to the question left open above: a conducting block a wire merely
points into is strongly powered, and no other dust can see that, because
while any wire is asking what its block power is, every wire in the world
temporarily answers that it is not a source at all.
The lever, two dust, and a powered piston
sequenceDiagram
participant LevB as LeverBlock
participant SL as ServerLevel
participant CNU as Collecting<br/>NeighborUpdater
participant RWB as RedstoneWireBlock
participant DRWE as DefaultRedstone<br/>WireEvaluator
participant PBB as PistonBaseBlock
Note over LevB,PBB: one call stack, inside one packet handler, before the level ticks
LevB->>SL: setBlockAndUpdate, POWERED with flags 3
SL->>CNU: updateNeighbors<br/>AtExceptFromFacing<br/>drained at once
CNU->>RWB: neighborChanged at the first dust
RWB->>DRWE: updatePowerStrength, and the lever answers 15
DRWE->>SL: setBlock POWER 15 with flag 2, then seven updateNeighborsAt
CNU->>RWB: neighborChanged at the second dust, ahead of the lever's other directions
RWB->>DRWE: updatePowerStrength, block signal 0 and a wire at 15
DRWE->>SL: setBlock POWER 14 with flag 2, then seven more
CNU->>PBB: neighborChanged, and the wire answers from its east side
Note over CNU,PBB: the remaining dozens of updates change nothing
LevB->>SL: updateNeighborsAt, two more fan-outs by hand
The same cascade in time, and the thing to watch is the order: the second dust’s whole turn — its recompute, its write and its seven fan-outs — finishes before the lever has issued its own remaining directions. That is the queue being drained depth-first, inside one call stack, with no tick anywhere in it.
The lever is the place in this trace where the client does not even try.
LeverBlock.useWithoutItem writes no state on a ClientLevel — it spawns a
particle, and only when the lever is going on — so unlike a door, a lever is
not predicted, and the client’s dust changes colour only when the block
updates arrive. The dust and the piston do nothing on the client either, but
for the ordinary reason: nothing ever calls their
BlockBehaviour.neighborChanged there. The sound
follows the same split for a different reason: LeverBlock.pull is handed a
null player, so nobody is excluded and the clicker hears the server’s
ClientboundSoundPacket like everyone else. Compare
block interaction, where the door passes the clicker
as except and they hear their own prediction instead.
Two details in the diagram are worth naming. The lever writes through
LevelWriter.setBlockAndUpdate, which is flags 3, Block.UPDATE_ALL, so Block.UPDATE_NEIGHBORS
fans out once before LeverBlock.updateNeighbours fans out twice more, at
the lever’s own position and at the block it stands on — and because nothing
was running when the first one was queued, it drains the entire dust cascade
before the other two are even issued. And the ordering of the seven positions
a wire updates is fixed by no array: they come out of a hash set. The depth-first drain of
CollectingNeighborUpdater is the subject of block
interaction,
and it is what puts the second dust’s whole cascade ahead of the
lever’s remaining directions.
Placing and breaking a wire take a wider path again:
RedstoneWireBlock.onPlace and
RedstoneWireBlock.affectNeighborsAfterRemoval both call
RedstoneWireBlock.updateNeighborsOfNeighboringWires, which walks the four
horizontals and then the diagonals — reaching over a conducting neighbour and
under a non-conducting one — and calls
RedstoneWireBlock.checkCornerChangeAt on each. That is another seven
Level.updateNeighborsAt per wire found. Which write does what is
blocks and states.
The second implementation
FeatureFlags.REDSTONE_EXPERIMENTS is a feature flag (identifiers and
registries),
turned on by a built-in data pack whose entire content is the line that enables
it (the resource system), and
RedstoneWireBlock.useExperimentalEvaluator asks the level for it on every
call — RedstoneWireBlock.evaluator is always the default one, and an
ExperimentalRedstoneWireEvaluator is a fresh object per update, because it
carries working state. What changes is the semantics, and the cost with it.
Run the same lever back the other way — off, so the line has to go dark, which
is where the default evaluator does its counting down — and follow the two dust
through
ExperimentalRedstoneWireEvaluator.calculateCurrentChanges instead. Nothing
is written until the whole connected network has been computed. Phase one
drains ExperimentalRedstoneWireEvaluator.wiresToTurnOff, and this is the
phase that kills the staircase: a wire whose recomputed power is lower than
what it holds goes to zero in a working map rather than to its new value,
so the two dust do not count down past each other, 13 and 12, 11 and 10, on
the way to nothing — each goes straight to nothing, and each is re-queued for
the second phase only if it has block power of its own or a stronger wire
beside it. Phase two drains
ExperimentalRedstoneWireEvaluator.wiresToTurnOn and raises each survivor to
its true value, which reaches the world in one write. Both phases spread through
ExperimentalRedstoneWireEvaluator.propagateChangeToNeighbors and
ExperimentalRedstoneWireEvaluator.enqueueNeighborWire, recording every wire
they reach in ExperimentalRedstoneWireEvaluator.updatedWires, an
insertion-ordered map of position to a packed orientation and power — which is
the working state that makes the evaluator a fresh object per call. The write
pass at the end drops any entry whose stored power already matches, so what
reaches the world is exactly the wires that really changed — each with
Block.UPDATE_CLIENTS and, for every wire the pass touches,
Block.UPDATE_SKIP_SHAPE_UPDATE_ON_WIRE, the bit that makes
NeighborUpdater.executeShapeUpdate skip any shape update whose target is
dust. (The one wire exempted from that bit is the one an evaluation started
from, and then only when the caller was RedstoneWireBlock.onPlace, which
wants the shape updates around a newly placed wire.)
The fan-out is the second difference, and it is what the seven positions
become. ExperimentalRedstoneWireEvaluator.causeNeighborUpdates issues one
Level.neighborChanged per connected side per changed wire — the four
horizontals the wire’s own state records, plus Direction.DOWN
unconditionally and Direction.UP never — in
Orientation.getDirections order, which is derived from where the update
came from rather than from a fixed array, and then five more at any side that
is a redstone conductor. So the piston east of our two dust is still told, and
told once, without the seven-position scattergun. The recursion closes because
RedstoneWireBlock.neighborChanged ignores wire-sourced updates entirely in
this mode; the default evaluator does not, which is what opens it.
For a 1.21-era reader.
BlockBehaviour.neighborChangednow takes a nullableOrientationrather than a sourceBlockPos— which is what lets the experimental evaluator order a fan-out relative to where the update came from — andBlockBehaviour.affectNeighborsAfterRemoval, which replaced the old removal hook, does not take one at all. RedStoneWireBlock is nowRedstoneWireBlock, and the repeater and observer tests thatRedstoneWireBlock.shouldConnectToheld are gone from it: each block answersBlockBehaviour.shouldRedstoneWireConnectTofor itself.
Where to look
The three questions a block answers are
BlockBehaviour.BlockStateBase.isSignalSource,
BlockBehaviour.BlockStateBase.getSignal and
BlockBehaviour.BlockStateBase.getDirectSignal; SignalGetter.getSignal
combines the last two with BlockBehaviour.BlockStateBase.isRedstoneConductor,
the strong half through SignalGetter.getDirectSignalTo, and
SignalGetter.getBestNeighborSignal over SignalGetter.DIRECTIONS is how a
position is read. The trace starts at LeverBlock.pull with
LeverBlock.updateNeighbours, enters the wire at
RedstoneWireBlock.neighborChanged, and the number is decided by
RedstoneWireBlock.getBlockSignal against
RedstoneWireEvaluator.getIncomingWireSignal, with
DefaultRedstoneWireEvaluator.updatePowerStrength doing the write and the
fan-out. For the drawing rules read RedstoneWireBlock.getConnectionState and
BlockBehaviour.shouldRedstoneWireConnectTo; for what leaves a wire,
RedstoneWireBlock.getSignal. The second implementation is
ExperimentalRedstoneWireEvaluator.calculateCurrentChanges and
ExperimentalRedstoneWireEvaluator.causeNeighborUpdates.
SignalGetter.getControlInputSignal is not named above and is the door into
what a diode reads from its sides.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Pistons and block events
Verified against Minecraft 26.3 · Part V · A powered piston pushes one stone block, and the client is never told where the moving blocks are.
A piston cannot act when it is asked. A repeater told about a change books a
turn in the appointment book and a wire recomputes itself on the spot;
PistonBaseBlock.checkIfExtend does neither. It appends a four-value record
to a set on the level and returns, and the push happens later, in a phase of
the level tick named for exactly this. What comes out the other side is
stranger still: in a push that destroys nothing, nothing the push writes sends
a block update for the moving blocks. The placeholders the server writes carry
Block.UPDATE_CLIENTS clear, so nothing incremental is generated for them, and the copy on your
screen exists only because your client re-ran PistonBaseBlock.moveBlocks
itself against its own world, off a single ClientboundBlockEventPacket. It
is not a prediction that gets confirmed. Nothing checks that the two
animations agree; if that one packet is lost you watch nothing move, and only
the landing write puts the block where everyone else already sees it.
The cast
| class | what it decides | thread |
|---|---|---|
BlockEventData | the record itself: a position, a block, and two ints | a record, no thread |
ServerLevel | that a block event is queued rather than run, and the one phase per tick in which the queue drains | Server |
Level | that on any other level — which in practice means ClientLevel — a block event runs immediately | Render |
PistonBaseBlock | whether to extend, which of three events to raise, and — at the drain, having re-checked — what actually moves | Server, then the client’s copy |
PistonStructureResolver | the set of blocks that move and the set that is destroyed, or a flat refusal | either side, allocated per attempt |
MovingPistonBlock | the placeholder state that occupies a position during the motion | either side |
PistonMovingBlockEntity | the two ticks of motion, the entities shoved along, and what is left behind at the end | both sides, in the block-entity phase |
PistonHeadBlock | the arm once the motion is over, and forwarding neighbour updates back to the base | Server |
The queue, and which tick it drains in
Level.blockEvent runs BlockBehaviour.BlockStateBase.triggerEvent on the
spot. ServerLevel.blockEvent overrides it to add a BlockEventData to
ServerLevel.blockEvents and return. That single override is the whole
mechanism, and it is the reason the two sides of a piston behave so
differently: the server always defers, the client never does.
The drain is ServerLevel.runBlockEvents, in the blockEvents section of
ServerLevel.tick, after tickPending and chunkSource and before
entities (the level tick). It is worth
being exact about what that timing means, because “a block event is a tick
late” is only sometimes true:
- Queued by a packet handler — the same tick.
MinecraftServer.processPacketsAndTickdrains the queued packets and then callsMinecraftServer.tickServerin the same lap (the server tick), so a lever a player flipped is handled before the level ticks at all, and the event it raised is drained in that same level tick. - Queued by a scheduled tick — the same tick. tickPending runs before blockEvents, so a repeater firing into a piston is also drained immediately (scheduled ticks).
- Queued by another block event — the same tick.
ServerLevel.runBlockEventsdrains until the set is empty, so an event raised while the drain is running is taken by the same drain. - Queued by an entity or a block entity — the next tick. Those phases run
after blockEvents, so anything they raise waits a full lap. A landing
PistonMovingBlockEntityis one step short of this group: it raises no event itself, but the neighbour bit of its landing write can reach a neighbouring piston, whosePistonBaseBlock.checkIfExtendthen queues one for next tick. - In a chunk that is not block-ticking — parked. Such an event goes to
ServerLevel.blockEventsToRescheduleand is re-added after the loop, so it is retried next tick rather than dropped.
ServerLevel.blockEvents is a linked hash set, so two identical events
pending at once collapse into one. And ServerLevel.doBlockEvent re-reads
the position and runs the event only if the block there is still the block the
event named — the same promise a scheduled tick makes. When
BlockBehaviour.BlockStateBase.triggerEvent returns true, and only then, a
ClientboundBlockEventPacket goes to every player within 64 blocks.
Who else uses the channel
The piston is the mechanism’s most demanding customer but not its only one.
Three blocks raise events directly — PistonBaseBlock, NoteBlock and
PotentSulfurBlock, the geyser, whose event carries no data at all and exists
only to have each side stamp its own game time on its block entity as the
eruption’s start — and
seven block entities raise their own, reaching
themselves back through BaseEntityBlock.triggerEvent: ChestBlockEntity,
EnderChestBlockEntity, ShulkerBoxBlockEntity, BellBlockEntity,
DecoratedPotBlockEntity, SpawnerBlockEntity and TheEndGatewayBlockEntity
(a chest lid, an ender chest, a shulker box, a bell, a decorated pot, a
spawner and an end gateway), all animated on clients that own no copy of their
state. The other forty-odd block entities in the game raise none, which is
why a block event is a small channel rather than a general one.
ComparatorBlock is the odd one out and worth a moment: it overrides
BlockBehaviour.triggerEvent to forward to its block entity,
but ComparatorBlockEntity overrides nothing and nothing anywhere raises a
comparator event, so the override is dead in both directions.
One push, tick by tick
Six flag words go past on this page, and four are worth having in hand: 324 is skip the block entity’s side effects, moved by piston, invisible; 276 swaps the piston bit for known shape; 67 is moved by piston, clients, neighbours; and 3 is the ordinary neighbours and clients. Of the four, only 67 and 3 tell a client anything. The table in the write nobody is told about has the first three row by row, with 82 and 18, which are sent too, the other way to end has the fourth, and the bit names are the catalogue’s (block update flags).
sequenceDiagram
participant SL as ServerLevel
participant PBB as PistonBaseBlock
participant PSR as PistonStructure<br/>Resolver
participant PMBE as PistonMoving<br/>BlockEntity
participant CPL as ClientPacketListener
participant CL as ClientLevel
Note over SL,CL: tick N, a packet handler, before the level ticks
SL->>PBB: neighborChanged, so checkIfExtend
PBB->>PBB: getNeighborSignal finds the wire, EXTENDED is false
PBB->>PSR: resolve, as a dry run
PSR-->>PBB: the stone is pushable, air beyond it
PBB->>SL: blockEvent, TRIGGER EXTEND with the facing packed in
Note over SL,CL: tick N, block-events phase, still the same tick
SL->>SL: doBlockEvent re-reads the position
SL->>PBB: triggerEvent
PBB->>PBB: getNeighborSignal, a second time
opt still powered — a pulse shorter than the gap dies here
PBB->>PSR: resolve, for real this time
PBB->>SL: setBlock, two MOVING PISTON placeholders at flags 324
PBB->>SL: setBlockEntity, one PistonMovingBlockEntity per placeholder
PBB->>SL: setBlock, the extended base at flags 67
SL-->>CPL: ClientboundBlockEventPacket within 64 blocks, and a sound packet
CPL->>CL: blockEvent, run at once rather than queued
CL->>PBB: triggerEvent again, against the client's world
end
Note over SL,CL: tick N and tick N plus 1, block-entities phase, both sides
PMBE->>PMBE: progress 0 to 0.5 to 1, moveCollidedEntities under NOCLIP
Note over SL,CL: tick N plus 2 on the server, five PistonMovingBlockEntity.deathTicks later on the client
PMBE->>SL: setBlock, the real stone and a PISTON HEAD, flags 67
One extension, from the redstone update to the landing write. The bracket is the whole of the point: everything inside it, the block-event packet the client re-runs included, happens only if the wire is still powered when the block event is drained, later in the tick that queued it.
How a piston decides, and the line that cannot fire
Quasi-connectivity is two loops, not a rule
PistonBaseBlock.getNeighborSignal is the whole of quasi-connectivity, and it
is one short method. Its first loop asks SignalGetter.hasSignal at all six
neighbours except the one it faces; then, if none of those answered, a second
loop repeats the same question for five of the neighbours of the position directly above the
piston, skipping Direction.DOWN because that would only read the piston
again. The piston is not the only block that reaches up like this, but the
family is tiny: DispenserBlock.neighborChanged asks
SignalGetter.hasNeighborSignal at its own position or at the one above it,
and DropperBlock has the behaviour too, by extending DispenserBlock without
overriding that method — so three classes reach up and only two write it
down, and no other block in the game does either. (DoorBlock asks the same
pair, but for a door the position above is its own upper half.) That is why
quasi-connectivity is a short list of block-by-block quirks rather than a
redstone rule. What signal means, and how the wire beside the piston comes to
be connected to it at all, is signal and dust.
The call between the loops is dead
Between those two loops sits a third question — SignalGetter.hasSignal at the
piston’s own position, looking down — and it can never return true. A
position answers with strong power only if the block standing there is a
redstone conductor (signal and
dust), and
Blocks.pistonProperties declares a piston never to be one; PistonBaseBlock
overrides no signal method of its own either, so its weak answer is zero as
well. Strong power reaches a piston the ordinary way, through its conducting
neighbours, in the first loop. The middle call is dead.
Three events, and what the third one declines to do
PistonBaseBlock.checkIfExtend turns the answer into one of three events.
Powered and not extended raises PistonBaseBlock.TRIGGER_EXTEND — but only if
a dry-run PistonStructureResolver.resolve succeeds first, so a piston with
an immovable wall in front of it queues nothing at all. Unpowered and extended
raises PistonBaseBlock.TRIGGER_CONTRACT, or
PistonBaseBlock.TRIGGER_DROP when the extension it would retract is still in
flight: the block two ahead is still a Blocks.MOVING_PISTON facing the same
way and extending, and either its progress is under half, or it was ticked this very
game tick, or ServerLevel.isHandlingTick says the level is still inside the
window that opens at the top of the tick and closes when this drain ends (the
level
tick).
The two retraction events run the same branch of
PistonBaseBlock.triggerEvent, and they differ in exactly one thing: a sticky
piston contracting normally may drag the block two ahead back with it, and a
drop may not — the pull is guarded on the event being
PistonBaseBlock.TRIGGER_CONTRACT. A sticky piston whose extension is caught in
flight leaves the block it was carrying where it stands: if the block is still
moving at the drain, PistonMovingBlockEntity.finalTick sets it down in place of
any pull, whichever the event, and if it has only just landed, the guard
keeps a drop from pulling it back. That is the thing players discover by
accident.
What moves, and what is simply gone
PistonStructureResolver runs at least twice per push on the server — a dry
run each time PistonBaseBlock.checkIfExtend finds the piston powered and not
extended, then once for real inside PistonBaseBlock.moveBlocks — and once
more in the client’s copy, and produces two
lists, PistonStructureResolver.toPush and PistonStructureResolver.toDestroy.
PistonStructureResolver.addBlockLine walks backwards first, from the block it
starts at and while the block it has reached is sticky, then forward until it
runs out of blocks, refusing the whole push, not merely stopping, when the
forward walk meets something unpushable or the blocks to move pass twelve in
all; walking backwards, an unpushable block only ends the walk. Twelve is the number
PistonStructureResolver.MAX_PUSH_DEPTH names, tested in three places.
PistonStructureResolver.addBranchingBlocks follows slime and honey sideways,
with PistonStructureResolver.canStickToEachOther refusing the one pairing
everybody tests first — slime against honey does not stick.
PistonBaseBlock.isPushable is the per-block veto and it is a longer list
than folklore suggests: outside the build height or the world border, a
block whose destroy speed is −1, a PushReaction of PushReaction.IMMOVEABLE
(which is how obsidian and fourteen other blocks, the anvils and the lodestone
among them, say it, as data), a
PushReaction.POPPED where the caller did not allow destruction, a
PushReaction.PUSH being moved the wrong way, an
already-extended piston, and — the clause that explains the most —
anything else with a block entity that would not pop. A push straight down at the bottom of the
world or straight up at the top is refused too, and a piston itself skips the
destroy-speed and push-reaction tests entirely. A chest cannot be pushed
because it has a block entity; that PistonMovingBlockEntity has nowhere to
keep one is the reading of the code that makes sense of the clause, not
something the code says.
The write nobody is told about
An extension and a sticky pull write five kinds of position between them, and
their flag words are the page’s hook made concrete. Four are
PistonBaseBlock.moveBlocks’s; the fifth, on an extension, is written by
PistonBaseBlock.triggerEvent after PistonBaseBlock.moveBlocks returns. Which bit does
what is blocks and states.
| what | written by | flags | the bits that matter |
|---|---|---|---|
| each moving block’s destination, and the arm | PistonBaseBlock.moveBlocks | 324 | Block.UPDATE_SKIP_BLOCK_ENTITY_SIDEEFFECTS, Block.UPDATE_MOVE_BY_PISTON, Block.UPDATE_INVISIBLE — and no Block.UPDATE_CLIENTS |
| an old arm cleared when a sticky piston pulls | PistonBaseBlock.moveBlocks | 276 | the same three bits again, with Block.UPDATE_KNOWN_SHAPE in place of the piston bit — also not sent |
| a vacated position, set to air | PistonBaseBlock.moveBlocks | 82 | Block.UPDATE_MOVE_BY_PISTON, Block.UPDATE_KNOWN_SHAPE, Block.UPDATE_CLIENTS — this one is sent |
| a destroyed block, set to air | PistonBaseBlock.moveBlocks | 18 | Block.UPDATE_KNOWN_SHAPE, Block.UPDATE_CLIENTS |
| the piston base, now extended | PistonBaseBlock.triggerEvent | 67 | Block.UPDATE_MOVE_BY_PISTON, Block.UPDATE_CLIENTS, Block.UPDATE_NEIGHBORS |
A retraction also writes the base’s own placeholder at 276, from
PistonBaseBlock.triggerEvent before any of these, and a piston that does not
pull clears its arm with Level.removeBlock, at 3. In this page’s trace
nothing is written at 82 at all — every origin down a
straight push is somebody else’s destination, so the vacated set empties — and
the reader should take the table’s point from the two rows that do fire here:
the client is told that the base is extended, and nothing about the two
positions now holding placeholders. Each placeholder’s
PistonMovingBlockEntity is injected by hand, built by the static
MovingPistonBlock.newMovingBlockEntity and installed with
Level.setBlockEntity —
MovingPistonBlock.newBlockEntity, the ordinary factory, returns null, because a moving piston is
never created by the ordinary block-entity path (block
entities) — and carries the real
block as PistonMovingBlockEntity.movedState. That state is also the one thing
about the moving blocks that does travel as data: PistonMovingBlockEntity overrides
BlockEntity.getUpdateTag, so a player who loads the chunk mid-push receives
the placeholder and its cargo in the chunk packet rather than as an update.
ClientPacketListener.handleBlockEvent hands the packet to the base
Level.blockEvent, which runs PistonBaseBlock.triggerEvent immediately
against the ClientLevel: the same resolver, the same placeholders, the same
injected block entities. What the client’s copy does not do is the
server’s-side-only work — no drops for a crushed block, no game event, no
BlockBehaviour.BlockStateBase.affectNeighborsAfterRemoval — and, most
audibly, no sound. PistonBaseBlock.triggerEvent passes a null except
entity, so nobody is excluded and there is no local prediction to hear: the
piston you hear is the server’s ClientboundSoundPacket, arriving beside the
event (what makes a sound). The particles of a crushed
block are the mirror image: the level event that spawns them is raised inside
PistonBaseBlock.moveBlocks on the client side only, and only for a block
outside BlockTags.FIRE.
Two ticks of motion, and two ways to end
PistonMovingBlockEntity.tick runs in the block-entity phase on both sides
and does one thing per tick: add 0.5 to PistonMovingBlockEntity.progress,
after shoving whatever is in the swept slab (bar the few entities that ignore
pistons) — PistonMath is the class that
computes it — with PistonMovingBlockEntity.moveCollidedEntities and dragging honey-stuck
entities with PistonMovingBlockEntity.moveStuckEntities. The
PistonMovingBlockEntity.NOCLIP thread-local is set around each entity’s own
move — and holds the push Direction rather than a flag — so that a pushed
entity may pass through the very block pushing it.
The two ticks are the number PistonMovingBlockEntity.TICKS_TO_EXTEND names.
Note where those two ticks start. The placeholders are written during the blockEvents phase, and a ticker goes straight into the level’s flat list — so the blockEntities phase, later in the very same tick, already walks it. The motion’s first tick is the tick the piston was told about, not the one after, which is why a piston is not “a tick late” so much as three ticks long.
The tick after progress reaches 1 is the landing. The entity is removed, and
Block.updateFromNeighbourShapes re-fits the moved state to its new
surroundings before it is written at flags 67, with a waterlogged property
cleared if it survived the trip. The arm’s position becomes a real
PistonHeadBlock, and that block earns its place in the cast by being the
piece of the assembly that talks backwards once the motion is over:
PistonHeadBlock.neighborChanged forwards the updates it receives to the base
behind it whenever it has one, so power arriving at the head reaches the piston
that owns it, and PistonHeadBlock.affectNeighborsAfterRemoval destroys that
base when the head is broken, which is why an arm cannot be mined off a piston
and left behind. The client holds five extra
PistonMovingBlockEntity.deathTicks before writing the landing itself; on
screen the block has already arrived, its progress having reached 1, and the
server’s own write is broadcast anyway.
The other way to end
PistonMovingBlockEntity.finalTick is a different operation, not an
early-exit form of that one, and the difference is what makes an interrupted
extension clean up after itself. It writes at flags 3 — plain neighbours and
clients, without the moved-by-piston bit the ordinary landing carries — and for
the entity carrying the arm (the one with
PistonMovingBlockEntity.isSourcePiston) it writes air instead of the
moved state, so a retraction that catches its own extension in flight leaves
nothing behind. That flag is set both on the arm’s placeholder and on the one a
contracting piston writes at its own position. It is reached from
PistonMovingBlockEntity.preRemoveSideEffects, and directly from
PistonBaseBlock.triggerEvent’s contract branch.
Questions players ask
Why did my zero-tick pulse move nothing at all? Because
PistonBaseBlock.triggerEvent asks PistonBaseBlock.getNeighborSignal again
at the drain, and a piston no longer powered simply returns false — which is
what a pulse that ends before that tick’s blockEvents phase leaves. The event
is consumed, no blocks move, and no ClientboundBlockEventPacket is sent, so
nobody sees anything happen either.
Why does a redstone block in front of a piston not power it? Because the
piston never asks there.
PistonBaseBlock.getNeighborSignal deliberately skips the direction the
piston faces when looking for power, so the block in front never counts as a
source — and then goes looking one block higher, where a redstone build would
not think to check.
Where to look
The channel first: Level.blockEvent against ServerLevel.blockEvent is the
one override the page turns on, ServerLevel.runBlockEvents and
ServerLevel.doBlockEvent are the drain, and
BlockBehaviour.BlockStateBase.triggerEvent is what a block is finally asked.
Then the piston: PistonBaseBlock.checkIfExtend decides,
PistonBaseBlock.getNeighborSignal is quasi-connectivity,
PistonBaseBlock.triggerEvent runs at the drain and
PistonBaseBlock.moveBlocks does the writing.
PistonStructureResolver.resolve with PistonStructureResolver.addBlockLine
is what moves and PistonBaseBlock.isPushable what does not. The motion is
PistonMovingBlockEntity.tick and its two endings,
PistonMovingBlockEntity.finalTick being the interesting one.
ClientPacketListener.handleBlockEvent is the client’s entire share.
BaseEntityBlock.triggerEvent and
PistonMovingBlockEntity.moveCollidedEntities are doors this page only points
at: the seven block entities that animate themselves, and what a push does to
whatever is standing in the way.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Diodes and the observer
Verified against Minecraft 26.3 · Part V · A repeater, a comparator and an observer in one circuit — three blocks that learn about the world three different ways, and one of them is not listening to redstone at all.
Put a repeater, a comparator and an observer side by side and they look like
variations on one idea: blocks that take a signal in one side and
push one out the other. Two of them genuinely are — RepeaterBlock and
ComparatorBlock are both DiodeBlocks and share their output machinery,
differing in the number they emit and in what calls it. The observer is not a diode at all — it is
a DirectionalBlock, a full cube, with a six-way facing — and the way it finds
out that
something changed is the page’s hook: ObserverBlock fires from
ObserverBlock.updateShape, a shape update, so the one block whose entire
job is noticing change is not on the channel that carries change
notifications. That is not a curiosity. It is why an observer sees a door
opened by hand, an event that writes with Block.UPDATE_NEIGHBORS clear and
so fires no neighbour update of its own. And the repeater quietly uses the
same trick for its lock.
The cast
| class | what it decides | thread |
|---|---|---|
DiodeBlock | everything the repeater and the comparator share: what counts as input, what counts as a side input, and how output leaves | Server |
RepeaterBlock | a delay in two-tick units, and whether it is locked | Server |
ComparatorBlock | two modes over one pair of inputs, and how far in front of itself it can see | Server |
ComparatorBlockEntity | one integer — the comparator’s whole reason for having a block entity at all | Server |
ObserverBlock | that a neighbour’s state changed, on the channel the repeater uses for its lock | Server |
Level | that a write carrying the neighbour bit, of a state with an analog output, pokes the comparators around it — and so does BlockEntity.setChanged | Server |
The observer shares one row with the other two
The table below is two comparisons wearing one grid, and it repays reading in that order. Read the first two columns against each other and you get the repeater against the comparator — four rows of difference, about reach, arithmetic, urgency and storage, and all of them inside machinery the two share. Then read the third column against the other two and there is only one row where it does what both of them do by the same calls: how it outputs. It reads no signal, offers no side input, and is told about the world on a different channel from their input, which is why nothing is the honest answer in two of its cells.
RepeaterBlock | ComparatorBlock | ObserverBlock | |
|---|---|---|---|
| what it reads from the front | DiodeBlock.getInputSignal — the signal at the block it faces, and if that is under 15, the raw RedstoneWireBlock.POWER of a wire there | the same, then overridden: an analog output if the block in front has one, else one block further through a conductor | nothing. It reads no signal at all |
| what it reads from the sides | DiodeBlock.getAlternateSignal, restricted to other diodes (DiodeBlock.sideInputDiodesOnly is true), and used only to lock | the same, unrestricted, and used as the second operand | nothing |
| how it books its turn | DiodeBlock.checkTickOnNeighbor unchanged: RepeaterBlock.DELAY doubled, at one of the three priorities that method can choose | overrides it entirely: always a delay of 2, at TickPriority.HIGH or TickPriority.NORMAL | ObserverBlock.startSignal from a shape update: delay 2 at the default TickPriority.NORMAL, and only if one is not already booked |
| what it stores | everything, in the block state | the same, plus one int in a ComparatorBlockEntity | everything, in the block state |
| how it outputs | DiodeBlock.updateNeighborsInFront | the same | ObserverBlock.updateNeighborsInFront, an independent copy making the same two calls |
A diode never writes into its target
The output half is the least-known part of all three blocks, and the two diodes
genuinely share it: one method on DiodeBlock, inherited unchanged. The
observer does the same two things by its own copy of the method and offers its
signal the same way, which is where its machinery converges with theirs.
A diode declares itself a source unconditionally
(DiodeBlock.isSignalSource), answers DiodeBlock.ownSignal with
DiodeBlock.getOutputSignal when DiodeBlock.POWERED and zero otherwise, and
restricts DiodeBlock.getSignal to the one neighbour at its output — so a repeater
offers its 15 to precisely one neighbour, and DiodeBlock.getDirectSignal
hands out the same value, which is what makes a diode able to strongly power
the block in front of it.
A diode’s HorizontalDirectionalBlock.FACING points at the input.
DiodeBlock.getStateForPlacement takes the player’s horizontal direction and
reverses it, so the output is at
Direction.getOpposite of its facing, and that is the position
DiodeBlock.updateNeighborsInFront acts on. It does two things there: a
direct Level.neighborChanged on the output block, and a
Level.updateNeighborsAtExceptFromFacing around that same block, skipping the
direction that points back at the diode. It never writes a state into the
target. It notifies, and lets the target read back through
SignalGetter.getSignal — which is why the signal passes from a repeater to a
repeater without either of them knowing what the other is; only how urgently
the feeding one books its turn, below, depends on that.
The repeater’s signal leaves by an unexpected door. DiodeBlock.tick writes
DiodeBlock.POWERED with Block.UPDATE_CLIENTS alone — flags 2, no
neighbour bit (block update
flags) — so Level.setBlock’s neighbour fan-out never runs, though its
three shape passes still do. What actually
propagates the change is DiodeBlock.onPlace, which
LevelChunk.setBlockState runs on the server for any write without
Block.UPDATE_SKIP_ON_PLACE, and which calls
DiodeBlock.updateNeighborsInFront
(blocks and states). The
comparator’s tick is its own, ComparatorBlock.refreshOutputState, which calls
DiodeBlock.updateNeighborsInFront directly as well (below).
What each one can see
DiodeBlock.getInputSignal reads the block it faces and then, if that gave
less than 15, takes the maximum with the raw RedstoneWireBlock.POWER of a
wire sitting there — the special case that lets a diode read a wire whose
connection state does not point at it.
DiodeBlock.getAlternateSignal reads the two horizontals perpendicular to the
facing through SignalGetter.getControlInputSignal, and
DiodeBlock.sideInputDiodesOnly decides what counts. For a repeater it is
true, so only another diode can reach a repeater’s side, and the value is used
solely by RepeaterBlock.isLocked. For a comparator it is false, so a
redstone block reads as 15, a wire reads as its power, and any signal source
reads as its strong output — and the value is the comparator’s second operand.
ComparatorBlock.getInputSignal is where comparators earn their reputation.
If the block in front has an analog output, that value replaces the redstone
reading outright. Otherwise, if the reading is under 15 and the block in front
is a redstone conductor, the comparator looks one block further and takes
the best of two things there: the analog output of whatever block is at that
position, and the reading of an ItemFrame — but only if exactly one frame
in that block’s space faces the way the comparator’s HorizontalDirectionalBlock.FACING points. The
direction test comes first and the count second, so a frame pointing some other
way is not counted at all; two frames facing the comparator’s way, and the
comparator reads neither. Note what it falls back to when both of those come
up empty: not zero, but the ordinary front reading it already had. A comparator
in front of a conductor with two item frames behind it is not broken — it is
reading the conductor.
What it does with the two numbers
Having read a front value and a side value, ComparatorBlock.calculateOutputSignal
is two lines. A side reading greater than the front one puts the output at
zero, whatever the mode. Otherwise ComparatorBlock.MODE decides, and it holds
one of exactly two values: in ComparatorMode.SUBTRACT the output is front
minus side, and in ComparatorMode.COMPARE — the default, and the mode a
freshly placed comparator is in — it is the front value unchanged. So the
comparing mode does no arithmetic at all; what it compares is only whether the
side beats the front. The lit pair of torches at its input end is a separate question
again: ComparatorBlock.shouldTurnOn decides DiodeBlock.POWERED, which
lights them, true when the front beats the side, and on a non-zero tie only
in compare mode — which is why a subtract comparator with equal inputs outputs
zero and its pair goes out, while a compare comparator with equal inputs
outputs the full front value and stays lit. The lone torch at its output end is
the mode’s, lit in subtract.
A container’s analog output is
AbstractContainerMenu.getRedstoneSignalFromContainer: every slot’s count
divided by that stack’s maximum size, summed, divided by the number of slots,
and mapped onto 0–15. (The formula takes the smaller of that maximum and the
container’s own cap, but the cap is 99 for every container that reaches it.)
So a chest of shulker boxes and a chest of cobblestone at the same item count
read very differently.
Booking a turn, and why a repeater turns off first
All three answer a change by booking a turn rather than acting on it; the queue itself, its dedup rule and the drain are scheduled ticks, which traces a repeater in full. What belongs here is which priority each one asks for, because that is where the two diodes stop agreeing.
DiodeBlock.checkTickOnNeighbor books only when the diode is not locked, only
when the current DiodeBlock.POWERED disagrees with the current input, and
only when LevelTickAccess.willTickThisTick says nothing is already about to
run there. It picks TickPriority.EXTREMELY_HIGH when
DiodeBlock.shouldPrioritize holds — when the block it outputs into is itself
a diode whose own input is not on the far side of it, so a diode reading this
one or standing sideways to it, but not one aimed back at it —
TickPriority.VERY_HIGH when the diode is currently on, and
TickPriority.HIGH otherwise. So a diode’s turn-off beats another’s turn-on
due on the same tick, and a diode feeding a diode beats both. The only
TickPriority.NORMAL booking a repeater makes is DiodeBlock.setPlacedBy, at
delay 1, when you place one into an already-powered spot.
ComparatorBlock.checkTickOnNeighbor throws all of that away. It has no lock
to consult — DiodeBlock.isLocked is false unconditionally and the comparator
does not override it — its delay is a flat 2 whatever the state says, and it
chooses between TickPriority.HIGH and TickPriority.NORMAL alone; the two
urgent priorities a repeater relies on are not available to it. It also books
on a second condition the repeater does not have: not only when the powered
flag disagrees with the input, but whenever the computed output value
differs from the int currently in the block entity.
DiodeBlock.tick is what pulse extension is made of. Finding itself off, it
turns on regardless of whether the input is still there — and then, if the
input has already gone, books its own turn-off one delay later at
TickPriority.VERY_HIGH. A pulse shorter than the delay is not swallowed; it
is stretched to the delay.
The channel the observer listens on
Two channels carry news of a change, and the difference between them is the
whole of this page’s hook, so it is worth stating before the figure draws it.
The neighbour channel — Level.updateNeighborsAt reaching each neighbour’s
BlockBehaviour.neighborChanged — is what a write enters when it carries
Block.UPDATE_NEIGHBORS, and it exists only on the server, where those methods
have bodies. The shape channel — Level.neighborShapeChanged reaching each
neighbour’s BlockBehaviour.updateShape — runs on every write that does not
say Block.UPDATE_KNOWN_SHAPE, and it runs on both sides. So the neighbour
channel says something near you changed and here is who says so, and the
shape channel says your neighbour’s state is now this; do you still fit? —
which arrives unless the writer opts out with that bit, where the neighbour
channel arrives only if the writer opts in (blocks and
states owns both).
flowchart TD
NC["the neighbour channel: Level.updateNeighborsAt"]
SC["the shape channel: Level.neighborShapeChanged"]
RB["DiodeBlock.neighborChanged, on the repeater and the comparator"]
RL["RepeaterBlock.updateShape writes LOCKED"]
DONE["no appointment: the lock is already correct"]
OB["ObserverBlock.updateShape"]
SS["ObserverBlock.startSignal"]
BOOK["the appointment book"]
DT["a diode's tick writes POWERED under UPDATE_CLIENTS"]
OT["ObserverBlock.tick writes POWERED under UPDATE_CLIENTS"]
OUT["the block in front, then its five other neighbours"]
NC -- "the write set UPDATE_NEIGHBORS" --> RB
SC -- "the write omitted UPDATE_KNOWN_SHAPE" --> RL
SC --> OB
RB -- "DiodeBlock.checkTickOnNeighbor" --> BOOK
RL -- "server only, off the axis" --> DONE
OB -- "facing direction, while unpowered" --> SS
SS -- "server only, none booked" --> BOOK
BOOK --> DT
BOOK --> OT
DT -- "DiodeBlock.onPlace inside the write, and a comparator's direct call" --> OUT
OT -- "ObserverBlock.updateNeighborsInFront, directly" --> OUT
Two channels, one queue, and one end that never reaches it: a diode hears about a change on the neighbour channel and the observer on the shape channel, both book a turn, and only the repeater’s lock is written where it stands. The paths out of the book differ too — the repeater’s pulse leaves through the side effect of its own write, while the observer calls for it and the comparator does both.
An observer watches for ObserverBlock.updateShape arriving from the one
direction it faces, while ObserverBlock.POWERED is false, and books a
two-tick appointment. Two ticks later ObserverBlock.tick writes the powered
state with flags 2, schedules its own turn-off two ticks after that, and
pulses through ObserverBlock.updateNeighborsInFront. The shape channel is
the right one for the job because it carries your neighbour’s state changed
unless the writer opted out: a door opened by hand writes
with flags 10 and issues no neighbour update at all, and the observer still
sees it (block interaction), and dust the observer is watching
carries the same news through the flag-2 writes of signal and
dust.
RepeaterBlock.LOCKED works the same way, and it is one of exactly two diode
properties computed from a redstone reading at all: DiodeBlock.POWERED is the
other, and the difference between them is when. DiodeBlock.POWERED is
written at tick time, from the appointment the block booked, and a comparator’s
mode click refreshes it on the spot; RepeaterBlock.LOCKED is written inside a
shape update or at placement, with no appointment anywhere.
RepeaterBlock.updateShape recomputes it whenever a neighbour off the
facing axis changes, which is the two sides and, harmlessly, up and down; the
value itself comes from the two sides alone. So locking follows a neighbouring
repeater’s state without either block scheduling anything.
The tempting conclusion is that both blocks chose the shape channel because it
is the half of the update machinery a client also runs. That is not what the
code does: both hooks refuse to do their redstone work on the client.
RepeaterBlock.updateShape recomputes the lock only when the level is not
client-side, and ObserverBlock.startSignal returns immediately on a
ClientLevel. Nor would it help the observer if it did — a client keeps no
appointment book at all, so a scheduled tick could never fire there. Beyond what
the client predicts for its own clicks and placements, everything else it knows about these three blocks the server sends it.
One int, and the fan-out that exists to deliver it
A comparator has a block entity for one reason:
ComparatorBlock.calculateOutputSignal can produce a number that the block
state has nowhere to keep. DiodeBlock.POWERED is one bit, and a comparator’s
output is 0–15. Plenty of redstone blocks have block entities — the sculk
sensor keeps its last vibration frequency in one and answers with it while it is
active, and most containers answer from their contents — but a DaylightDetectorBlockEntity
stores nothing at all, so having one is no evidence of state that a block state
could not hold.
ComparatorBlockEntity.getOutputSignal is that number, written by
ComparatorBlock.refreshOutputState — which also flips the powered bit when
it needs to, and then calls DiodeBlock.updateNeighborsInFront whether or not
anything changed, whenever the mode is ComparatorMode.COMPARE.
The other half of making comparators work is
Level.updateNeighbourForOutputSignal. It walks the four horizontals and
notifies any Blocks.COMPARATOR it finds; failing that, where the neighbour is
a redstone conductor, it reaches one further and notifies a comparator there
instead, mirroring the comparator’s own reach in the opposite direction. Twelve
call sites reach it, and the two general ones are the pair that matters here.
Level.setBlock calls it on the server for a write that carries
Block.UPDATE_NEIGHBORS and whose new state has an analog output — but an item
entering a chest is not a write at all. That path is BlockEntity.setChanged,
which calls it unconditionally, and it is what makes a comparator notice a
hopper filling a chest that nothing else in redstone would have reported.
Where to look
Read the shared half first, because it is where the two diodes stop being two
blocks: DiodeBlock.getInputSignal and DiodeBlock.getAlternateSignal are the
two readings, DiodeBlock.sideInputDiodesOnly is the one boolean that turns a
repeater’s side input into a lock, DiodeBlock.checkTickOnNeighbor books the
turn and DiodeBlock.shouldPrioritize decides how urgently, DiodeBlock.tick
is where pulse extension lives, and DiodeBlock.updateNeighborsInFront with
DiodeBlock.onPlace is the whole of how a repeater’s signal leaves. Then the two
divergences: ComparatorBlock.getInputSignal with
ComparatorBlock.calculateOutputSignal and ComparatorBlock.refreshOutputState
for the arithmetic and the int in the block entity, and
ObserverBlock.updateShape with ObserverBlock.startSignal for the channel.
RepeaterBlock.updateShape is the lock, and
Level.updateNeighbourForOutputSignal is the fan-out built for comparators.
AbstractContainerMenu.getRedstoneSignalFromContainer is the door into what a
chest is worth.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
VI · Entities
Verified against Minecraft 26.3 · Part VI · Everything in the world that is not a block: what one is, who is allowed to move it, how it is described to the other side, and how it stops.
A zombie glides where your own movement is crisp. A sheared sheep changes on
every screen at once. A mob you name-tagged never despawns by distance, a hit that
lands during the red flash usually does nothing, and Strength II raises your
damage without a single attribute packet leaving the server. Those are five different systems and one
question: which of the two programs is allowed to decide this, and how does
the other one find out? Part VI is that question asked about everything in
the world that is not in the grid — the zombie, the arrow, the boat, the
dropped pickaxe, the invisible marker a data pack left as a bookmark. All of
them are an Entity, a base class deliberately thin on behaviour: it gives
them a box, a numbered array for describing themselves to clients, one
collision resolver and one abstract method for being hurt, and not much else
besides the riding, fire, fluids and portals they all share. So the answer to the question is never in one place. It is five
mechanisms that each decide it separately, and the part’s claim is that
every surprise on this list is one of those five answering a question you
thought a different one had already settled.
The shape of the part
Part VI is a ladder, and the numbers on it are the watch order below. Each page needs the ones above it and nothing below them, and the second rung — authority — is the one everything else leans on, including Parts VIII, IX and X, which link back to it rather than re-deriving it.
flowchart TD
A["1 · Entity anatomy"]
B["2 · Authority"]
C["3 · Entity lifecycle"]
D["4 · Synched entity data"]
E["5 · Attributes"]
F["6 · Movement and collision"]
G["7 · Goals and brains"]
H["8 · Pathfinding"]
I["9 · Damage and death"]
OUT["Parts VIII, IX and X"]
A -- "one type, one factory, one live object" --> B
B -- "one side of each pair decides where it goes" --> C
C -- "now it is in a world, findable and ticking" --> D
D -- "one of six channels, and here is another" --> E
E -- "the physics knobs are attributes" --> F
F -- "something has to decide where to walk" --> G
G -- "a decision is only a position" --> H
H -- "and one abstract method answers every hit" --> I
B -- "and so do three later parts" --> OUT
The ladder, numbered to the watch order, where every arrow means what the next page needs from this one, so the labels are why the order is the order. The rung with two arrows out is the one to notice: authority is the only page in Part VI that three later parts name as a prerequisite.
Two of the eight steps down the ladder are pairings rather than sequels. Synched entity data and attributes are two channels doing the same job differently, and the contrast is the lesson, so the second wants the first fresh in mind. Pathfinding is the other half of goals and brains and watchable on its own once that page has said where a wanted position comes from.
The arrow leaving rung two for Part X lands on two pages, and it is Part X’s dependency rather than this part’s: the client level opens by saying it is not an authority either, and prediction and acknowledgement calls itself the client’s second answer to the same question, and neither lands until authority has said what one is. So this part is watched first, and nothing here waits on Part X.
Before you start
The level tick,
because two of this part’s surprises are claims about which phase something
ran in — the entity phase runs after the phase that broadcasts entity
changes, which is why an attribute change waits for the next tick the entity’s
update interval allows, while a sheep sheared out of the packet
queue,
before the tick proper, does not. Tickets and
loading, because whether an
entity ticks at all is a property of the chunk it is standing in — for
everything except a player, which is exempt — and chunk
anatomy for the heightmaps that
decide where a mob may spawn. Points of
interest,
because a villager’s whole day is claims on them and goals and
brains leans on PoiManager rather than explaining
it. Blocks and
states for the
shapes that entities collide with. Part IV’s scheduled ticks is a page of the
world part you can skip before this one: entities keep no appointment book.
One more Part IV page, for one lecture: environment attributes and
timelines
before goals and brains. A villager’s timetable is a
data-pack Timeline looked up at a position, and this part asks that system a
question rather than teaching it.
Watch in this order
- Entity anatomy — one
EntityTypefrom the registry, through a factory, to a live object the level ticks. The registry’s default is a pig, and that default reaches the network and never reaches your save file. - Authority — a zombie, a player and a boat each take one step on both sides. The client runs no physics at all for the mob chasing you, and the server runs your own player’s physics every tick and then throws the answer away.
- Entity lifecycle — a zombie appears in the dark, is ticked for a while, and is either forgotten or written to disk. The spawner rolls one height per category per chunk each time it tries, so caves and the open field compete for the same slice.
- Synched entity data — a sheep is sheared and
every screen in range agrees within the tick. The slot the wool lives in
is written nowhere in
Sheep: it is 18 because eighteen slots were handed out above it, and the ids stop at 254 because 255 means stop. - Attributes — Strength II lands, and no attribute packet is sent at all. Eight of the forty attributes never reach the client, and attack damage is one of them.
- Movement and collision — one tick of a falling zombie. The mover answers what did I walk through afterwards, by replaying the tick’s movement, and it settles fire and water touched in one step in a fixed order, so they always end in the extinguish.
- AI: goals and brains — a villager’s day, and the same machinery under a zombie that has none of it. The timetable is not on the villager: it goes to bed because it asked the world what time it is where it is standing.
- Pathfinding — the other half of the same lecture. Giving up is machinery: the node being walked towards carries a timeout, and the mob you watch walk into a fence and then wander off is running a scheduled surrender.
- Damage and death — the part’s closer. An arrow, eight owners of one number, and one abstract method. A hit that lands inside the red flash usually does nothing at all, and when it is stronger than the last, only its excess lands — silently.
Where the part stops
This is the largest part of the book by lines —
766 files and 112,091 lines in world/entity (less
world/entity/player), network/syncher, world/level/pathfinder,
world/damagesource and world/effect — and
33% of the part’s lines are named on no page in the book. Most of that is one class
per species, and a species is the same nine pages instantiated: Panda is
1,121 lines of Mob with a sitting animation, and the 103 behaviour classes
and 61 goal classes are nearly all one of three shapes, drawn once on goals and
brains.
Four mechanisms there are more than a species and are explained nowhere — the
minecart’s two movement models, the ender dragon’s eleven phases, a
raid, and villager gossip — and a second edition should take them. The part
also stops one rung short of Avatar: everything player-shaped is Part VIII,
drawing an entity is Part XI, and the prediction ledger is Part
X. Two packages above have
their lecture elsewhere: world/effect, because the split an effect
demonstrates is the player’s (status effects),
and world/entity/ai/village/poi, lectured with the rest of the world’s
claims in points of interest.
Reference this part uses
Five were written for this part.
Attributes — all forty, with defaults,
ranges and the syncable flag, generated from the registrations.
Entity data serializers — all
44, in wire-id order, which is registration order.
Entity spawn reasons — all nineteen, with
the classes that behave differently for each; six are labels nothing tests.
Structure spawn overrides —
which six structures replace a biome’s spawn list, and what they put there
instead. Damage outside LivingEntity — what
each of the twenty-two non-living classes does with a DamageSource, on
either side.
Then the shelf the whole book shares:
packets,
registries,
game rules — ten of which this part’s pages
name — math and primitives for
AABB and VoxelShape, the glossary,
threads and diagram
lanes.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Entity anatomy
Verified against Minecraft 26.3 · Part VI · What an entity is: one
EntityTypefrom the registry, through a factory, to a live object the level ticks.
You type /summon pig, and by the next tick there is a pig standing where
you are. Between the command and the animal are three objects and one factory
call: a ResourceKey in EntityTypeIds, the EntityType that EntityTypes
built from it before any world existed, and the Entity that the type’s
EntityType.EntityFactory returns. This page is the vocabulary of that chain,
and the rest of Part VI is built on it. The surprise is what happens when the
kind is one nobody has heard of, because that depends entirely on which of two
doors it came through. An entity arriving over the network is named by a
number, the registry index inside a ClientboundAddEntityPacket; an entity
read back off disk is named by a string, the id field in the region file.
Those two doors give opposite answers to the same bad name.
Registries.ENTITY_TYPE is one of the few defaulted registries, its
default is pig, and DefaultedMappedRegistry overrides nine methods, seven
of which hand that default back — five for a miss, among them
DefaultedMappedRegistry.byId and DefaultedMappedRegistry.getValue, and
DefaultedMappedRegistry.getAny and DefaultedMappedRegistry.getDefaultKey
always.
So the numeric door cannot fail: ByteBufCodecs.registry decodes through
IdMap.byIdOrThrow, which cannot throw here because
DefaultedMappedRegistry.byId never returns null, and a pig walks out of the
packet. The string door is not defaulted at all, because EntityType.CODEC is
Registry.byNameCodec and resolves through a lookup the subclass leaves alone
— as does DefaultedMappedRegistry.getOptional, the one override that
deliberately answers empty. So the same unknown name in a region file yields
nothing: EntityType.create logs Skipping Entity with id … and leaves a hole
where the entity was. The default reaches the network and never reaches your
save file.
The cast
| class | what it decides | thread |
|---|---|---|
EntityType | one registered kind: its factory, category, frozen dimensions, feature flags, and the two numbers that decide how it reaches clients | built in a class initialiser, read from both game threads and the worldgen executor after |
EntityTypes | which 161 kinds exist and what every one of them is sized like — the most useful single table in the package | class initialiser, once |
Entity | position, box, network id, synched values, vehicle, removal reason. Deliberately thin on behaviour | the tick thread of whichever level owns it, or the worldgen executor before its chunk is live |
EntityDimensions | width, height, eye height, attachment points, and whether Attributes.SCALE may touch them | immutable record; the type’s is shared by every entity of the type |
SynchedEntityData | which of the entity’s fields the other side is told about (synched entity data) | one container per side; only the server’s writes travel |
EntityInLevelCallback | whether the object is in a world or merely on the heap | installed by the level’s entity manager |
ServerEntity | when a tracked entity’s state becomes packets (what the client is told) | the Server thread |
EntityReference | how one entity remembers another across a chunk unload | wherever it is resolved |
Only one packet is this page’s own: ClientboundAddEntityPacket, the birth
announcement. Everything after it — ClientboundSetEntityDataPacket,
ClientboundEntityPositionSyncPacket, ClientboundEntityEventPacket,
ClientboundRemoveEntitiesPacket — belongs to a sibling page.
The type is frozen, the entity is not
An entity is anything in the world that is not a block: mobs, players, items
on the ground, arrows, boats, item frames, experience orbs, the invisible
markers a data pack uses as bookmarks. The kind is an EntityType, built once
and never changed. The individual is an Entity, and nearly all of its state
is mutable by design.
classDiagram
class EntityType {
one object per registered kind, 161 of them
EntityType.EntityFactory, usually the constructor
MobCategory, the spawn cap and despawn distance
clientTrackingRange in chunks, updateInterval in ticks
frozen by EntityType.Builder.build
}
class EntityDimensions {
width, height, eye height
EntityAttachments, and a fixed flag
}
class Entity {
SynchedEntityData, eight accessors and the chain's
Entity.position at the feet, Entity.bb a stored box
Entity.dimensions and Entity.eyeHeight, both caches
Entity.levelCallback, a NULL sentinel until a level takes it
Entity.removalReason, the whole am-I-alive bit
Entity.vehicle and Entity.passengers
}
EntityType --> EntityDimensions : holds one, built once
EntityType ..> Entity : EntityType.create calls the factory
EntityDimensions ..> Entity : copied into the two caches, and recopied on a pose change
What each object holds, and the line the page turns on: everything in the top two boxes is frozen at class-initialiser time and shared by every individual of that kind, and everything in the bottom one belongs to one individual and changes.
Entity implements nine interfaces — Nameable, EntityAccess,
ScoreHolder, SyncedDataHolder, DataComponentGetter, ItemOwner,
SlotProvider, DebugValueSource and TypedInstance over EntityType —
which is a fair measure of how many systems reach into it. Health, AI, the
damage pipeline and inventory are all further down the tree.
In 26.3 the type constants are not on EntityType. They live in two
parallel files: EntityTypeIds, 161 ResourceKeys with no reference to any
entity class, and EntityTypes, the 161 matching objects that
EntityType.Builder produced from them. MobCategory — MobCategory.MONSTER,
MobCategory.CREATURE, MobCategory.AMBIENT, MobCategory.AXOLOTLS,
MobCategory.UNDERGROUND_WATER_CREATURE, MobCategory.WATER_CREATURE,
MobCategory.WATER_AMBIENT, MobCategory.MISC — carries the spawn cap
(entity lifecycle)
and the despawn distance
(entity lifecycle). None of
this is data-driven: Registries.ENTITY_TYPE is code-registered
(the data-driven type pattern
is what it is not), and what a
data pack can reach is the tags in EntityTypeTags, the loot table at
entities/<id>, DataComponents.ENTITY_DATA on a spawn egg, and the
per-species variant registries (Registries.WOLF_VARIANT,
Registries.CAT_VARIANT, Registries.PAINTING_VARIANT and the rest).
Dimensions, attachments and pose
EntityDimensions is a record — width, height, eye height, an
EntityAttachments map and a fixed flag — and
EntityDimensions.makeBoundingBox centres the box in X and Z on
Entity.position and grows it upward, because that position is the bottom
centre, the feet, and not the middle of the entity.
EntityDimensions.scale returns the record unchanged when it is fixed, and
also when both factors are 1. The flag is not the only way to escape Attributes.SCALE,
either: plain Entity.getDimensions never scales at all, so every non-living
entity ignores scale for free, and LivingEntity.getDimensions — which is
final, the overridable hook being LivingEntity.getDefaultDimensions —
short-circuits a sleeping entity to LivingEntity.SLEEPING_DIMENSIONS before
any scaling happens.
EntityAttachments answers where a passenger sits, where the entity itself
sits on a vehicle, where the name tag floats: EntityAttachment.PASSENGER,
EntityAttachment.VEHICLE, EntityAttachment.NAME_TAG and
EntityAttachment.WARDEN_CHEST, each with a fallback such as
EntityAttachment.Fallback.AT_HEIGHT that EntityAttachments.Builder.build
fills in from the width and height.
Pose is eighteen constants with explicit wire ids, not ordinals — the
ones that matter are Pose.STANDING, Pose.CROUCHING, Pose.SWIMMING,
Pose.FALL_FLYING, Pose.SLEEPING, Pose.SPIN_ATTACK and Pose.DYING, and
the rest are animation states of one mob or a few, such as Pose.EMERGING and
Pose.DIGGING. Pose.BY_ID is built with
ByIdMap.OutOfBoundsStrategy.ZERO, so a pose id outside the range decodes
silently to Pose.STANDING rather than failing the connection. Pose is the
synched value on the base class that changes the box — a dozen subclasses
have their own, from a pufferfish’s puff state to a slime’s size — and the
loop is worth stating
because everything else on this page hangs off it: Entity.setPose writes the
value, SynchedEntityData.set calls Entity.onSyncedDataUpdated
inside the setter, before anything is marked dirty, and that sees
Entity.DATA_POSE and calls Entity.refreshDimensions, which asks
Entity.getDimensions for the new record, overwrites both caches, and calls
Entity.reapplyPosition to rebuild the box. The side that set the pose
resizes immediately, the other resizes when the value lands. If the box grew,
Entity.fudgePositionAfterSizeChange nudges the entity out of whatever it now
overlaps — but only on the server, only after the first tick, only with
physics on, only when the entity is not a Player, and only when the new box
is at most four blocks in both width and height.
That loop is also the answer to why did the hitbox not change when I changed
the size. Entity.dimensions, Entity.eyeHeight and Entity.bb are caches,
and two things in the base classes refresh them unasked: a pose change, in
Entity, and LivingEntity.onAttributeUpdated on Attributes.SCALE. The dozen
subclasses with a box-changing value of their own — AgeableMob on the
baby bit among them — each call Entity.refreshDimensions for it, and
everything else has to call it by hand. While a cache is stale the two
eye-height accessors disagree: Entity.getEyeHeight with no argument reads the
cache, and the Pose overload recomputes.
The tree, and the class that was inserted into it
Entity has 18 direct subclasses and 192 descendants. LivingEntity and
its own 124 make 125 of them — two thirds of the tree in one branch — and the
seventeen non-living branches hold the other 67.
Entity tree to three levels, generated from the decompile: a name in brackets carries how many types descend from it, and a grey line folds a node's childless children when there are two or more of them. Click to enlarge.The full drawing, with the block, item and screen trees beside it, is in
what extends what. What matters here is the shape:
a long spine and a scattering. The spine is LivingEntity, and for all that it
carries it has exactly three direct subclasses — Avatar, ArmorStand and
Mob —
which is worth saying plainly, because it means an armour stand is a living
entity with no AI at all: no GoalSelector, no PathNavigation, both of
those being Mob’s. The Brain is not Mob’s, though. It is declared on
LivingEntity, built in its constructor and written under a Brain tag by
every living entity, so an armour stand carries an empty one.
PathfinderMob is a small class that scores walk targets and adds no way of
moving, which is why Ghast and Phantom navigate without ever being one.
AgeableMob adds babies, TamableAnimal an owner reference and a tame bit,
Animal and Monster split by disposition, and Monster implements Enemy,
a marker interface carrying nothing but XP-reward constants.
The 67 outside LivingEntity’s branch are shallow, and the atlas draws them as
four families and a scattering: Projectile with 26 descendants,
VehicleEntity with 15, BlockAttachedEntity with 6, Display with 3, and
thirteen direct subclasses of Entity with no
children of their own, from ItemEntity to LightningBolt. Sharing a base class that thin is what lets
them disagree so completely about being hit
(damage and death).
Avatar sits between LivingEntity and Player. It is a small class
carrying the player-shaped Avatar.POSES dimension map, Avatar.DEFAULT_EYE_HEIGHT
of 1.62 and one abstract method, Avatar.getProfile, so anything written
against “Player extends LivingEntity” is wrong by one level. Its point
is Mannequin, a posable, profile-skinned, player-looking entity in the
decoration package that is not a Player and carries none of the
inventory, abilities or hunger. What the rung holds and who draws it belong to
player anatomy;
what matters here is that the tree has that level.
Cutting across the tree are the capability interfaces, where most of the
shared behaviour lives: Leashable is the fattest, with
Leashable.tickLeash called from Entity.baseTick, and beside it
Bucketable, EquipmentUser, NeutralMob, Attackable, Targeting,
TraceableEntity, OwnableEntity, Shearable, PlayerRideableJumping,
ItemSteerable, and ContainerUser, with exactly two implementors: Player
and CopperGolem.
EntityReference deserves a name of its own. It stores either a UUID or the
live object, and EntityReference.getEntity resolves lazily and decays back
to the UUID the first time it is resolved after the target reports itself
removed. It is how “who last hurt me”, “who owns this pet” and “who shot this
arrow” survive a chunk unload.
Where the 727 files are
| subpackage | files | what |
|---|---|---|
entity/ai | 277 | goals, brains, navigation, attributes, sensors |
entity/animal | 130 | one subpackage per species or family now |
entity/monster | 84 | mostly likewise |
world/entity itself | 85 | Entity, LivingEntity, Mob, Avatar, EntityType, EntityTypes, the capability interfaces |
entity/projectile | 37 | arrows, fireballs, thrown items |
entity/boss | 24 | dragon and wither |
entity/vehicle | 24 | boats and minecarts |
entity/npc | 15 | villagers and traders |
entity/player | 14 | Player, Inventory, Abilities |
entity/decoration | 13 | armour stands, frames, paintings, Mannequin |
entity/variant | 11 | the machinery that picks a data-driven mob variant |
entity/item, entity/raid, entity/ambient, entity/schedule | 13 | items, raids, bats, and Activity — which is all entity/schedule still holds |
From a registry entry to a live object
EntityTypes runs EntityType.Builder.build for each of the 161 keys in
EntityTypeIds and registers the result, and it is that call which freezes
the EntityDimensions and its attachment points for the life of the type.
Everything below happens per entity, long afterwards.
sequenceDiagram
participant SumC as SummonCommand
participant ET as EntityType
participant Entity as Entity
participant SL as ServerLevel
participant PESM as PersistentEntity<br/>SectionManager
SumC->>SumC: rejects out of bounds, then on peaceful
SumC->>ET: loadEntityRecursive, a tag with an id string
ET->>ET: EntityType.by reads the id through EntityType.CODEC
ET->>ET: EntityType.create checks canSpawn, then the factory
ET->>Entity: the constructor: the next id, a new UUID, the type's dimensions
Entity->>Entity: eight base accessors, then defineSynchedData, then setPos
ET->>Entity: load, then readAdditionalSaveData
SumC->>Entity: snapTo, the postLoad processor, before any passenger exists
ET->>Entity: each Passengers child the same way, then startRiding
SumC->>SL: tryAddFreshEntityWithPassengers, one body at a time
SL->>PESM: addNewEntity
PESM->>Entity: setLevelCallback, and only now is it in a world
Note over SL,Entity: the next server tick
SL->>Entity: commonTick, then tick
Thirteen steps between a registry entry and a live object, and the one that
matters is the second from last: everything above Entity.setLevelCallback
happens
to an object no level knows about.
The client builds its own copy of the same object, from a packet, whenever a player comes into range — which is the same journey a second time, in five steps instead of thirteen:
sequenceDiagram
participant Entity as Entity
participant SE as ServerEntity
participant CPL as ClientPacketListener
SE->>Entity: getAddEntityPacket
SE->>CPL: ClientboundAddEntityPacket, with data, attributes and equipment
CPL->>CPL: createEntityFromPacket, a second object
CPL->>CPL: Entity.recreateFrom<br/>Packet
CPL->>CPL: ClientLevel.addEntity
The client’s Entity is not the server’s: it is a separate object, of the
same class for all but a player or a mannequin, built from a packet rather than from a tag, and the two are only
ever as alike as the channels in the rest of this part keep them.
Name to type. EntityType.by reads the id field through
EntityType.CODEC. An unknown id yields nothing, and the entity is dropped
with the log line the opening quoted — this is the save-file half of the hook.
Type to object. EntityType.create checks EntityType.canSpawn: the
feature flags, plus a peaceful-difficulty test gated on the type’s own
EntityType.isAllowedInPeaceful flag, which is a declared property and not a
synonym for hostile. EntitySpawnRequest.ignoreChecks skips both.
SummonCommand tests the same peaceful rule itself, a few lines earlier, purely
to fail with a better message; the command does not set the skip flag, so both
run. One type fails EntityType.create always: EntityTypes.PLAYER was built
with EntityType.Builder.createNothing, so its factory returns null, which
is why ClientPacketListener.createEntityFromPacket special-cases it and
hand-builds a RemotePlayer from the player info it already has; it is
EntityType.Builder.noSave and EntityType.Builder.noSummon besides. Otherwise the
factory runs. The Entity constructor takes the next id from the level,
invents a UUID with Mth.createInsecureUUID, copies the type’s dimensions
into its cache, and builds the synched-data container — its own eight
accessors, then the abstract Entity.defineSynchedData, which contributes
nothing on the base class and exists only for the subclasses to chain up
through (synched entity
data
names the eight and says why their order is the whole numbering
scheme). It then calls Entity.setPos at the origin,
so a fresh entity already has a full-size box rather than the zero-size
Entity.INITIAL_AABB the field initialiser gave it.
Tag to state. Entity.load reads position (clamped to ±3.0000512E7
horizontally), motion, rotation and UUID, then calls the abstract
Entity.readAdditionalSaveData, then Entity.reapplyPosition again if
Entity.repositionEntityAfterLoad says so — which everything says except
BlockAttachedEntity, the corpus’s one override, so paintings, item frames,
leash knots and cushions are precisely the entities that skip it and keep the
position their own load computed. The save twin is Entity.addAdditionalSaveData, and
passengers save inside their vehicle: Entity.save returns false for anything
currently riding, and the vehicle writes them into its Passengers list
through Entity.saveAsPassenger, using Entity.getEncodeId, which is null
for types that never serialise.
Object to level. ServerLevel.tryAddFreshEntityWithPassengers refuses if
any UUID in the stack is already loaded, then
ServerLevelAccessor.addFreshEntityWithPassengers calls
ServerLevel.addFreshEntity once per body.
PersistentEntitySectionManager.addNewEntity files it into a section and
replaces EntityInLevelCallback.NULL with a real callback. That is the
moment it stops being an object on the heap;
entity lifecycle
takes it from here.
Level to client. ServerEntity.addPairing sends
ServerEntity.sendPairingData as one bundle: the packet
Entity.getAddEntityPacket returns — id, UUID, type, position, velocity,
three rotation bytes and one varint of type-specific data — then the
non-default synched values, then attributes and equipment. On the client,
ClientPacketListener.handleAddEntity builds the object, calls
Entity.recreateFromPacket, and only then adds it to the level.
The spawn-egg path is a different pipeline that meets this one at the end.
EntityType.spawn snaps the new entity out of collision with
EntityType.getYOffset, aligns head and body rotation, runs
Mob.finalizeSpawn, applies a PostSpawnProcessor, adds it through
ServerLevelAccessor.addFreshEntityWithPassengers and plays an ambient
sound. SummonCommand also calls Mob.finalizeSpawn, but only when the
command asks for it, and it never touches the Y offset.
The tick both sides share
ServerLevel.tickNonPassenger opens a profiler section named after the entity
type, calls Entity.commonTick — which counts Entity.invulnerableTime down,
calls Entity.setOldPosAndRot, steps the interpolation on a client and
increments Entity.tickCount — and then calls Entity.tick, all of it
through Level.guardEntityTick, which turns any exception into a crash report
with the entity’s details attached.
ClientLevel.tickEntities does the same, through the same guard, having first
skipped anything removed, riding or frozen by the tick-rate manager. Which
entities the server’s loop reaches, and in what order, belongs to the level
tick. No
entity is ever ticked on a worker pool, and Entity is not thread-safe —
though entities are constructed on one: ChunkStatus.SPAWN runs
NaturalSpawner.spawnMobsForChunkGeneration on the worldgen executor, so a
chunk’s first animals are built and finalised off the Server thread before the
chunk ever becomes live.
Entity.tick on the base class is one line: call Entity.baseTick.
Everything else readers remember happening “in tick” is in Entity.baseTick — the
dead-vehicle check, the boarding cooldown, the portal handling, the fluid
snapshot, swimming, fire ticking, the lava halving of fall distance, the
below-world check, the leash — or in an override. LivingEntity.tick calls up
and then runs LivingEntity.aiStep, and Mob.tick calls up and refreshes its
goal-control flags through Mob.updateControlFlags every five ticks, on the
server only — as the portal handling, the burning and the leash inside
Entity.baseTick are too.
What differs between the sides is not the tick but what the tick is allowed to do, and that is exactly the subject of the next page, authority.
The id, and what compares equal
Entity.equals compares the network id and nothing else — not the UUID, not
identity — and Entity.hashCode is the id. Ids are handed out by
ServerLevel.getNextEntityId from a static AtomicInteger on ServerLevel,
so they are process-global, which keeps two server levels’ ids apart, and the
level is consulted only to avoid a collision. Equal ids still meet across
levels: in singleplayer the client’s copy of an entity and the integrated
server’s share one, which is why entities from two levels must never go in one
set.
On the client there is no id at all until a packet supplies one.
Level.getNextEntityId returns a literal zero and ClientLevel does not
override it, while zero is the reserved invalid id and Entity.getId throws
Tried to access entity ID before ID assignment on it. Between construction
and the Entity.setId that Entity.recreateFromPacket performs, a
client-side entity has no id, and therefore no equality and no hash.
ServerLevel.getNextEntityId skips zero deliberately.
The two numbers frozen onto the type
The cast promised two numbers that decide how an entity reaches clients.
EntityType.clientTrackingRange is in chunks and
EntityType.updateInterval in ticks — 5 and 3 unless a builder says
otherwise — and between them they decide who is told about the entity and how
often its movement is sent, never mind what the packet says (what the client is
told
owns the asking, and a third parameter, EntityType.trackDeltas, which is
true of every type but eleven).
Thirty-eight of the 161 types set an interval explicitly, most of them at 10
or 20; eight set Integer.MAX_VALUE (most through
EntityType.Builder.noUpdateInterval), which ChunkMap turns into
UpdateInterval.NEVER, so their interval branch never fires at all —
EntityTypes.ITEM_FRAME and EntityTypes.GLOW_ITEM_FRAME,
EntityTypes.PAINTING, EntityTypes.LEASH_KNOT, EntityTypes.END_CRYSTAL,
EntityTypes.LIGHTNING_BOLT, EntityTypes.AREA_EFFECT_CLOUD and
EntityTypes.CUSHION. At the
other end EntityTypes.MARKER has a tracking range of 0 and is never sent to
anyone at all.
The client holds a lever over a distance of its own, how far away an entity is
still drawn (in singleplayer the same option also scales how far the integrated
server tracks), and it is process-global in the same way the ids are. Entity.viewScale is a static field on Entity,
and LevelExtractor writes it from the effective render distance and the
entity-distance option together, not from the option alone — which is why
entity render distance moves when you touch a slider that does not name
entities.
Where to look
Start at EntityTypes, which builds all 161 types and is the most useful
single table in the package, and read EntityType.Builder.build beside it for
what gets frozen. Then EntityType.create and EntityType.loadEntityRecursive
for the chain this page traces, and Entity itself — its constructor,
Entity.defineSynchedData and Entity.load — for what comes out of it.
Entity.refreshDimensions is the way into the dimension caches and
EntityDimensions.makeBoundingBox the arithmetic under them.
PersistentEntitySectionManager.addNewEntity is where an object becomes part
of a world. Two doors the page opens only a crack: EntityReference, for how
one entity remembers another, and EntityType.spawn, the spawn-egg path that
meets the trace at its end.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Authority: who is allowed to simulate
Verified against Minecraft 26.3 · Part VI · A zombie, a player and a boat each take one step, on the server and on the client, and only one side of each pair decides where it ends up.
A zombie walks towards you across a field. Both programs have a Zombie
object; both tick it every twentieth of a second; both call the same
Entity.tick. Only one of them works out where it goes. Standing next to
the zombie is another player, and their copy is the other way up — and the
boat you are steering is a third case again, authoritative on your machine
and nowhere else. Five predicates on Entity — two of them final — decide
all of it, and they invert the naive picture in both directions: the client runs no physics
at all for the mob chasing you, while the server runs your player’s physics
every tick and then overwrites the answer with a number your client sent.
This is the single most error-prone idea in the entity part, and this part and three later ones rest on it — movement and collision here, every page in Part VIII about a player, what the client is told in Part IX, and in Part X both the client level, for what the client is allowed to simulate, and prediction and acknowledgement, for what it is allowed to guess while it waits to be told. Nineteen pages in the book’s thirteen parts link back to this one. It is stated in full once, here.
Three cases, read on both sides
The whole answer fits in one table, and the rest of the page is that table explained. The columns are three shapes an entity can have; the rows are what each predicate answers and what follows from it.
| a tracked mob | a player | a boat you are steering | |
|---|---|---|---|
Entity.isClientAuthoritative | false | true, unconditionally | true, inherited from you |
Entity.isLocalInstanceAuthoritative, server | true | false | false |
Entity.isLocalInstanceAuthoritative, client | false | true only on your own player | true, on your machine only |
Entity.canSimulateMovement, server | true | true — its move-simulation type | false |
LivingEntity.travel runs on the server | yes | yes, and the result is overwritten | n/a |
LivingEntity.travel runs on the client | never | only for your own player | n/a — AbstractBoat.floatBoat and AbstractBoat.controlBoat do it instead |
Entity.checkFallDamage inside Entity.move | server only | your own client only — the server reaches it by the packet path | client only |
| what the other side does instead | is interpolated, and stands still when the interpolation runs out | applies your movement packet | applies its own copy’s packet |
A tracked mob is one the server has told your client about at all, which is
every mob you can see. A fourth shape is worth naming here so that it does not
look like an omission later: an entity nobody is riding and nothing is
steering — an empty boat, a minecart — reaches the base implementations,
which answer false to both roots, and is simulated by the server alone. A
dropped item, any projectile in flight, an experience orb, a falling block and
lit TNT (and a dying ender dragon) answer false to both roots too, but each
declares MoveSimulationType.SERVER_AND_CLIENT, so Entity.canSimulateMovement
is true for it on both sides.
The row that surprises people is Entity.checkFallDamage. Entity.move gates
it on Entity.isLocalInstanceAuthoritative, which for a player is true on your
own client and false on the server — so the copy that runs it every tick is
the one that cannot hurt you. Three names sit close together here:
Entity.checkFallDamage is the gate inside Entity.move;
LivingEntity.checkFallDamage overrides it and adds the landing particles only
on a ServerLevel, so your client’s copy accumulates the fall distance and
calls Block.fallOn, whose damage reaches Entity.hurt, which acts only on a
ServerLevel; and Entity.doCheckFallDamage is the entrance the server uses
instead, driven by the movement packet, which calls the same method and is Part VIII’s
subject.
The cast
| class | what it decides | thread |
|---|---|---|
Entity | the five predicates, and every gate inside Entity.move that reads them | the Server thread and the Render thread |
Player | overrides three of the five and answers its own move-simulation type, and is the reason the picture inverts | both |
Mob | narrows Entity.isEffectiveAi with Mob.isNoAi | both; the AI that acts on it runs on the server |
LivingEntity | LivingEntity.aiStep, which either simulates or coasts | both |
ClientPacketListener | whether an inbound position packet moves the entity or only updates a codec | the Render thread |
ServerGamePacketListenerImpl | re-runs the client’s numbers for the entities the client owns | the Server thread |
Five predicates, and the final one the other four hang off
Entity.isLocalInstanceAuthoritative is the root and it is final — no
class overrides it. It asks a different question on each side: on the client,
am I locally client-authoritative?; on the server, am I not
client-authoritative? Everything else hangs off those two, bar the answers a
type or Player gives first: on the server a player asks neither question,
and the entities above simulate on both sides whatever the root says.
flowchart TD
SIM["Entity.canSimulateMovement — final, it reads the type"]
AI["Entity.isEffectiveAi"]
SIMT["the type: a player's is not a client or the local player, and an item's, a projectile's, TNT's and a few more are both sides"]
Q["Entity.isLocalInstanceAuthoritative — final"]
AIP["Player: not a client, or I am the local player"]
AIM["Mob: and not Mob.isNoAi"]
LCA["Entity.isLocalClientAuthoritative"]
NCA["Entity.isClientAuthoritative, negated"]
LCAD["base: the controlling passenger, or false"]
LCAP["Player: am I the local player?"]
CAD["base: the controlling passenger, or false"]
CAP["Player: true, so the root is false"]
SIM -- "or" --> SIMT
SIM -- "by default" --> Q
AI -- "by default" --> Q
AI -- "or" --> AIP
AI -- "or" --> AIM
Q -- "on the client" --> LCA
Q -- "on the server" --> NCA
LCA --> LCAD
LCA --> LCAP
NCA --> CAD
NCA --> CAP
Every arrow means one thing, is answered by: two of the predicates are
answered by the root unless a type or Player’s override answers instead, and
the root is answered by the other two, one on each machine. Entity.canSimulateMovement
is final like the root, so what a player or an item changes there is its
move-simulation type, not the method.
Two things in that picture are easy to miss. The base implementations of
both Entity.isLocalClientAuthoritative and Entity.isClientAuthoritative
delegate to the controlling passenger — an entity with nobody steering it
answers false to both, and an entity with a controlling passenger inherits
that passenger’s answer.
That single line is the whole vehicle model. And Player answers
Entity.getMoveSimulationType with MoveSimulationType.AUTHORITATIVE_SIDE_AND_SERVER,
which the final Entity.canSimulateMovement reads, and overrides
Entity.isEffectiveAi, both to something different from the root — not a client, or I am the local player, the
second half being Player.isLocalPlayer — which
is what lets the server simulate a player it is not authoritative for. The
member that inverts the picture is Player.isClientAuthoritative, an
unconditional true.
Three classes narrow the AI predicate further and are worth naming because
they are the exceptions people trip over: Mob.isEffectiveAi adds
Mob.isNoAi, which is where the NoAI tag bites (what the guard
around it does and does not wrap is goals and
brains), and both
ArmorStand.isEffectiveAi and Mannequin.isEffectiveAi add a physics or
immovability flag of their own.
The mob: the client is not correcting, it is replaying
A client-side zombie fails Entity.isLocalInstanceAuthoritative because
nothing is riding it, so Entity.canSimulateMovement is false and
LivingEntity.aiStep never reaches LivingEntity.travel. What it does
instead is two things: Entity.commonTick steps its InterpolationHandler
just before the zombie’s own tick, and LivingEntity.aiStep opens with the
other half: when nothing is interpolating, scale the stored delta by
0.98. Nothing then applies that delta, because the only thing that would is
Entity.move, which nothing in the mob’s own tick reaches on this side, and
this is precisely the seam where the client’s other answer begins: what it may
guess rather than simulate belongs to prediction and
acknowledgement.
(A piston or a shulker box can still drive a client-side mob into
Entity.move, with a vector of their own rather than its stored delta —
movement and collision has the
five MoverType constants that say who is pushing.) Inside the mob’s own tick,
though, there is no collision, no gravity, no friction and no attempt at
prediction. It is not simulating and being corrected — it is replaying what
ClientboundMoveEntityPacket and ClientboundEntityPositionSyncPacket tell
it, and standing perfectly still when the interpolation runs out.
The player: simulated twice, believed once
A ServerPlayer passes Entity.canSimulateMovement and
Entity.isEffectiveAi — both true on the server, by Player’s move-simulation
type and its override — so the server’s copy runs the whole of
LivingEntity.travel. It also fails Entity.isLocalInstanceAuthoritative,
so the one gate inside Entity.move that reads it alone, the fall damage,
does not fire. The predicates are why a copy that is not authoritative simulates
at all; what the server then does with the answer is the two-phase
tick —
the physics run in the connection phase, after every level has ticked, inside
a bracket that puts the player straight back where it found them, and the
authoritative position moves in the movement packet instead. The server’s own
simulation is a sanity check whose position nobody uses.
Authority is also about which of two numbers is real
A player’s position on the server moved by the packet path rather than by the
server’s own arithmetic, so how far did this entity move this tick has two
possible answers for a player, and for anything a player is steering, and one
for everything else — and a block has to
know which it is being given. SweetBerryBushBlock.entityInside asks
Entity.isClientAuthoritative to decide how to find out:
Entity.getKnownMovement, the distance the client reported, for a player or
anything a player steers, whose movement the server did not compute, and
old-position-minus-current for everything else. The predicate is not only a switch for physics.
The boat: authoritative on exactly one machine
Sit in a boat’s first seat and the base delegation makes it yours. On your client
Entity.isLocalClientAuthoritative walks to the controlling passenger, finds
you, and returns true, so your machine simulates the boat for real. On the
server the same delegation makes Entity.isClientAuthoritative true, so the
server’s copy is not authoritative and does not simulate — it zeroes its
own delta outright. Every other client’s copy does the same, and is moved
by its LinearInterpolationHandler.
sequenceDiagram
box transparent your machine
participant LP as LocalPlayer
participant AB as AbstractBoat
participant CPL as ClientPacketListener
end
participant Wire as the network
box transparent the server
participant SGPL as ServerGamePacket<br/>ListenerImpl
participant SAB as AbstractBoat
end
AB->>AB: floatBoat, then controlBoat, then move for real
LP->>Wire: ServerboundMoveVehiclePacket.fromEntity, once per client tick
Wire->>SGPL: handleMoveVehicle
SGPL->>SAB: move, MoverType.PLAYER over the reported distance
alt the move is accepted
SGPL->>SAB: absSnapTo the reported position
SGPL->>SAB: setOnGroundWithMovement, then doCheckFallDamage
SGPL-->>Wire: nothing
else it is rejected
SGPL->>SAB: absSnapTo the old position
SGPL->>Wire: ClientboundMoveVehiclePacket
Wire->>CPL: handleMoveVehicle
CPL->>AB: absSnapTo the server's position
CPL->>Wire: ServerboundMoveVehiclePacket, echoed back to confirm
end
Two copies of one boat, one lane each: yours is simulated, and the server’s is moved over the distance yours reports and then kept or snapped back (a report too far off is refused before it is moved at all). The alt is one moment with two outcomes, and only the accepted one lands the fall damage on the server’s copy.
The inbound half of that is the sharpest demonstration of what the predicate
is for. ClientboundMoveVehiclePacket is not a routine update — the server
sends it only when it has rejected your movement, and
ClientPacketListener.handleMoveVehicle applies it
only for a vehicle the client is authoritative for, then immediately echoes a
ServerboundMoveVehiclePacket back to confirm. Meanwhile the ordinary
per-entity position packets take the opposite branch:
ClientPacketListener.handleEntityPositionSync and
ClientPacketListener.handleMoveEntity both check
Entity.isLocalInstanceAuthoritative and, when it holds, do not move the
entity — ClientPacketListener.handleMoveEntity decodes the delta into the
entity’s position codec and stops there, and
ClientPacketListener.handleEntityPositionSync records the absolute position
in the codec on either branch. The server’s opinion about where your boat is
gets recorded and ignored.
Where the gates actually sit
Authority is not one flag consulted once. Entity.move and
LivingEntity.aiStep read a predicate at eight places between them, and
one of the eight reads two, so there are nine readings in all:
| where | which member | reads |
|---|---|---|
the vertical collision flags, and Entity.setOnGroundWithMovement | Entity.move | locally authoritative or moved vertically — the horizontal flags are always updated |
| fall damage | Entity.checkFallDamage | locally authoritative |
| the bounce | Entity.restituteMovementAfterCollisions | Entity.canSimulateMovement |
the step sound and GameEvent.STEP | Entity.applyMovementEmissionAndPlaySound | not a client or locally authoritative |
| the 0.98 decay of the stored delta | LivingEntity.aiStep | Entity.canSimulateMovement is false |
| the AI step | Mob.serverAiStep | Entity.isEffectiveAi and not client-side |
| the travel fork | LivingEntity.travel | Entity.canSimulateMovement and Entity.isEffectiveAi — the one place that reads a pair |
| the block effects that follow travel | Entity.applyEffectsFromBlocks | the same not-a-client-or-authoritative test as the step sound |
The row worth slowing down for is the fifth. The 0.98 decay is what runs
because Entity.canSimulateMovement is false — the not-authoritative
branch, not a fallback inside the authoritative one — which is the whole of
what a client-side mob does with its stored delta.
The travel fork has a fork of its own in front of it, the ridden branch, which
movement and collision walks.
What belongs here is that LivingEntity.travelRidden is a ninth site:
an Entity.canSimulateMovement test that zeroes the delta outright when it
fails.
Questions players ask
Why does a mob rubber-band and my own player does not? On an ordinary
tick neither one is being corrected, and for opposite reasons. Your client does simulate your own
player, and the boat or horse you are steering — but it never simulates a
tracked mob, so there is nothing about the mob for the server to disagree
with. What you see on a mob is the gap between position packets, walked by
InterpolationHandler.
Why does a boat feel responsive and a horse feel heavy? You steer both,
and both are authoritative on your machine — but a horse is a LivingEntity
going through LivingEntity.travelRidden, which asks the mob for its own
speed and drag, while a boat runs its own physics directly. The authority
answer is the same; the layer above it is not.
Does NoAI stop a mob moving? On its own, completely — a NoAI mob does
not even fall. Mob.isEffectiveAi is the gate on Mob.serverAiStep, which is
the obvious half, and it is also the second half of the gate on
LivingEntity.travel, so nothing in the mob’s own tick reaches Entity.move.
What still moves it comes from outside: a piston, a shulker or a shulker box,
a vehicle carrying it, and a player steering it, whose branch asks only
Entity.canSimulateMovement.
Where to look
Read the five predicates first, from the root out:
Entity.isLocalInstanceAuthoritative, then the two it asks —
Entity.isLocalClientAuthoritative and Entity.isClientAuthoritative — then
Entity.canSimulateMovement and Entity.isEffectiveAi, which default to it.
Player.isClientAuthoritative and Mob.isEffectiveAi, with its
Mob.isNoAi, are the two overrides that matter. Then read the two methods that spend them, Entity.move and
LivingEntity.aiStep, with the table above open. For the packet half,
ServerGamePacketListenerImpl.handleMoveVehicle on the server and
ClientPacketListener.handleEntityPositionSync on the client are the two
branches the boat’s section turns on, and InterpolationHandler is what moves a
mob when nothing simulates it. SweetBerryBushBlock.entityInside is a door
worth opening for one line: it is the only place in the game that reads a
predicate to choose which measurement of a movement to believe.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Entity lifecycle
Verified against Minecraft 26.3 · Part VI · A zombie spawns in a dark chunk at night, is ticked for a while, and then either despawns or is written to disk when the chunk unloads.
Night falls, you are standing in a field, and somewhere behind you a zombie
appears. What the server actually did is smaller and stranger than it looks.
Once a tick, for each chunk near a player and each mob category still under
its cap, NaturalSpawner.getRandomPosWithin rolls a random x, a random z and
one y — a single uniform draw between the world bottom and the surface
height of that column (chunk anatomy).
Three attempts follow — each one a group, because a
successful attempt keeps spawning siblings around the first mob until a size
limit stops it — and each one makes a handful of tries that jitter only x
and z. So the whole of one category’s chance in one chunk this tick lives on
one horizontal slice: every eligible category gets its own slice, caves
and the open field compete for the same rolls, and a world with more vertical
space between bedrock and grass spreads those rolls thinner over the surface.
Everything else on this page — the caps, the light test, the despawn radius,
the write to disk — hangs off that one roll, or off the moment several
rejections later where the mob is finally allowed to exist.
The cast
| class | what it decides | thread |
|---|---|---|
NaturalSpawner | the cascade of tests between a chunk and a mob, run from one file of static methods | the Server thread, and a worker for generation’s spawn step |
NaturalSpawner.SpawnState | the per-tick census, the global cap per MobCategory, and the biome crowding budget through PotentialCalculator | the Server thread, rebuilt each tick |
LocalMobCapCalculator | whether any player near this chunk is still under the per-player limit | the Server thread, rebuilt each tick |
SpawnPlacements | the placement type, heightmap and predicate for each EntityType — code, not data | a static table, read on the Server thread, and on a worker by generation’s spawn step |
PersistentEntitySectionManager | which entities exist, which are findable, which tick, which chunks are queued to unload | the Server thread, with one concurrent load inbox |
Visibility | the three-state projection of FullChunkStatus that everything above reads | an enum, read on both sides |
EntityTickList | the set the tick walks, and the double buffer that makes mutating it mid-walk safe | the Server thread, and the Render thread for ClientLevel |
EntityStorage | the entities/ region files, separate from the block ones | reads and writes the file on the IO pool, turns tags into entities and back on the Server thread |
A spawn attempt is a filter, not a conversation
Almost every step of the spawner is a rejection, and the rejections are not all the same size: one drops a category for the whole tick, one skips a chunk for every category, one ends this category on this chunk, one ends a group attempt, and most of them cost nothing but a single jittered try. Drawing it as a conversation hides all of that. Here it is as the filter it is, in two halves — what has to pass before the first try, and what happens at each try after it — with the tests themselves in the table under them.
flowchart TD
CAT{"the category"}
CAT -->|"fails"| XCAT["out for the whole tick"]
CAT -->|"passes"| CHK{"the chunk"}
CHK -->|"fails"| XCHK["out for every category"]
CHK -->|"passes"| LOC{"the local cap"}
LOC -->|"fails"| XPAIR["this category on this chunk is over"]
LOC -->|"passes"| ROLL["the roll: one x, one z, one y"]
ROLL --> POS{"the position"}
POS -->|"fails"| XPAIR
POS -->|"passes"| JIT["three group attempts at that position"]
Three sizes of giving up before the first jittered try: a category can leave the whole tick, a chunk can leave every category, and the two of them can end together, at the local cap or at the rolled position. The rejecting edges are the subject of this page; the passing ones are only what is left.
Follow the right-hand edge to the bottom and the roll is the thing to stop at: one x, one z and one y, and everything below it happens at that one height.
flowchart TD
JIT["one jittered try"] --> TRY{"the group's species, then the type filter"}
TRY -->|"no species to pick"| GRP
TRY -->|"fails"| MORE{"tries left in this group?"}
TRY ==>|"passes"| MAKE{"EntityType.create"}
MAKE -->|"null"| OVER["this category on this chunk is over"]
MAKE -->|"a Mob at last"| OBJ{"the object filter"}
OBJ -->|"fails"| MORE
OBJ -->|"passes"| FIN["Mob.finalizeSpawn, then ServerLevelAccessor.addFreshEntityWithPassengers"]
FIN -->|"cluster full"| OVER
FIN -->|"a lone tropical fish"| GRP
FIN -->|"room for a sibling"| MORE
MORE -->|"yes"| JIT
MORE -->|"no"| GRP{"all three attempts spent?"}
GRP -->|"no"| JIT
GRP -->|"yes"| OVER
After the roll the ways out are cheap: almost every failure jitters x and z and tries again, three arrows end only the group attempt (a new one puts x and z back at the roll), and three reach the box at the foot. The thick edge is the boundary the rest of this section is about.
Four things in that loop are worth reading twice. Almost every rejection
merely jitters again — it costs one try, not one of the three attempts. The
three that end a group are the empty species list,
Mob.isMaxGroupSizeReached (which only a tropical fish that does not school
ever answers yes to) and the tries running out. And the two that
end the whole category on this chunk are a null from EntityType.create and a
full cluster, which is why one bad construction and one crowded spawn both
look, from outside, like the chunk going quiet. Only the last box is a spawn:
Mob.finalizeSpawn settles what the mob is, and
ServerLevelAccessor.addFreshEntityWithPassengers is the door the game’s other
spawners use too, or LevelWriter.addFreshEntity behind it for a single body.
What each test drops, in the order it runs
| the test | it fails when | what is given up |
|---|---|---|
NaturalSpawner.getFilteredSpawningCategories | it is a monster category and the monster game rules are off; it is a persistent category and the game time is not a multiple of 400; or NaturalSpawner.SpawnState.canSpawnForCategoryGlobal is already at the cap | the category, for the whole tick |
ChunkMap.collectSpawningChunks | the holder has no ticking chunk behind it, or ChunkMap.playerIsCloseEnoughForSpawning finds no non-spectator player within 128 blocks measured horizontally to the chunk centre | the chunk, for every category |
ServerLevel.canSpawnEntitiesInChunk | the chunk is not entity-ticking, or lies outside the world border | the chunk, for every category |
NaturalSpawner.SpawnState.canSpawnForCategoryLocal | LocalMobCapCalculator.canSpawn finds every nearby player at their own cap | this category on this chunk |
NaturalSpawner.getRandomPosWithin | the single y roll landed at the very bottom of the world | this category on this chunk |
| the block at the rolled position | it is a redstone conductor, and this is asked before any species is picked | this category on this chunk |
EntityGetter.getNearestPlayer | there is no non-spectator player anywhere in the level | this try |
NaturalSpawner.isRightDistanceToPlayerAndSpawnPoint | within 24 blocks of the nearest player; within 24 of the respawn point and that point is in this dimension; or the jitter left this chunk for one that cannot spawn | this try |
NaturalSpawner.getRandomSpawnMobAt | the list is empty — or the category is MobCategory.WATER_AMBIENT in a reduced-water-ambient biome, 98 times in 100 | this group attempt |
NaturalSpawner.isValidSpawnPostitionForType | the category is MobCategory.MISC; it is too far out for a type that cannot spawn far from a player; the type is unsummonable or no longer in the list at this exact block; SpawnPlacements.isSpawnPositionOk fails on the placement type; SpawnPlacements.checkSpawnRules fails, which is where Peaceful and the light test live; or the type’s spawn box collides with the world | this try |
NaturalSpawner.SpawnState.canSpawn | the biome’s crowding budget for this exact type is spent | this try |
EntityType.create | feature-flagged off (Peaceful was caught two rows up), or what it built is not a Mob | this category on this chunk |
NaturalSpawner.isValidPositionForMob | Mob.checkSpawnRules or Mob.checkSpawnObstruction fails on the real object, or Mob.removeWhenFarAway says it would despawn instantly anyway | this try |
Mob.getMaxSpawnClusterSize | the cluster is full | this category on this chunk |
Mob.isMaxGroupSizeReached | the mob is a tropical fish that does not school, the one type that ever answers yes | this group attempt |
The boundary that matters is the thick edge. Everything above it is decided
against the EntityType — the placement type, the light rule, the collision
box — because constructing a mob to ask it costs
more than answering from the type. Nothing above that line has an object to
call a method on. Monster.checkMonsterSpawnRules is the light rule for a
zombie, and it hands the light half to Monster.isDarkEnoughToSpawn, three
tests in a row: sky light against a random draw from
zero to 31, then the dimension’s DimensionType.monsterSpawnBlockLightLimit
if that limit is below 15, then the local brightness against a sample of
DimensionType.monsterSpawnLightTest. The last of those is where storms come
in: during thunder the brightness is computed with a fixed sky-darkening
of 10 instead of the level’s current one, which is what lets monsters spawn
outdoors in the daytime. The light rule is per-dimension data,
not a constant, and EntitySpawnReason.ignoresLightRequirements exempts
exactly one reason, EntitySpawnReason.TRIAL_SPAWNER.
The last gate before construction is the one nobody meets, and it is worth
knowing why. NaturalSpawner.SpawnState.canSpawn asks the position’s
EnvironmentAttributes.NATURAL_MOB_SPAWNS — the spawn settings every biome
file sets (biomes) — for a
MobSpawnSettings.MobSpawnCost for this exact type: a charge the mob adds to
a field of nearby charges and an energy budget the sum may not exceed, so a
species can be made to thin itself out over distance rather than over a cap.
PotentialCalculator is that field, and the rule is per biome and per type
rather than global. In vanilla data two biomes use it at all — soul sand
valley, for ghasts, skeletons, endermen and striders, and the warped forest,
for endermen alone. Everywhere else the biome has no cost for the type,
MobSpawnSettings.getMobSpawnCost returns null, and the gate passes without
arithmetic.
Construction is itself a filter, and the harshest-tempered one:
EntityType.create can return null (entity
anatomy has the two
gates it checks and the one request flag that skips them). What the spawner
does with a null is its own: NaturalSpawner.spawnCategoryForPosition returns
outright, not trying the next position. On Peaceful a type not allowed there
never gets this far: SpawnPlacements.checkSpawnRules refuses it first, one
try at a time.
The species it was about to build came from a list, and that list has a
hard-coded exception in front of it. NaturalSpawner.mobsAt asks
ChunkGenerator.getMobsAt, which walks the structures the position’s chunk references and, for the first one that declares a StructureSpawnOverride for this category and whose box — a piece’s or the whole start’s, depending on the override’s StructureSpawnOverride.BoundingBoxType — contains the position, replaces the biome’s list entirely with the structure’s. That is how an ocean monument spawns
guardians and empties itself of axolotls, and it is a data-pack field
(spawn_overrides) on the structure. Fifty-two shipped structures carry the
field and six fill it in — and of the twenty-three overrides those six
declare, eighteen name nothing at all, which replaces the list with an empty
one and so bans the category inside the box (structure spawn
overrides). Ahead of all of it sits
NaturalSpawner.isInNetherFortressBounds, which is not data-driven at all:
if the category is MobCategory.MONSTER and the block below is
Blocks.NETHER_BRICKS and the position is anywhere inside a fortress’s
bounds, the list is the constant NetherFortressStructure.FORTRESS_ENEMIES.
The fortress declares the same list as an override too — the code path just
gets there first, over a wider box.
The two caps, and where 289 comes from
A category must pass both caps before a chunk tries it, and they are counted
differently. The global
cap in NaturalSpawner.SpawnState.canSpawnForCategoryGlobal is
MobCategory.getMaxInstancesPerChunk — 70 for MobCategory.MONSTER, 10 for
MobCategory.CREATURE — times the number of spawnable chunks, divided by
NaturalSpawner.MAGIC_NUMBER. That divisor is 17², and the 17 is not
arbitrary: DistanceManager tracks spawn chunks out to eight chunks from
each player over a neighbourhood that includes diagonals, so one player
contributes a Chebyshev square of 17×17 chunks. The constant normalises the
cap back into seventy monsters per player’s worth of area, which is why it
grows with player count and shrinks when players stand together.
The local cap is LocalMobCapCalculator.canSpawn, and it is a veto
rather than a budget: it walks the players near this chunk and answers yes
the moment it finds one under the raw per-chunk number for the category. With
no player near the chunk the walk would find nobody and answer no, but the
natural spawner never gets there: near here is the same 128-block test to the
chunk centre two gates above, run over the same players in the same tick, so a
chunk that reached this gate always has someone near it.
(SharedConstants.DEBUG_IGNORE_LOCAL_MOB_CAP is the development switch that
turns that half off.) The census both caps count from skips any mob that is
Mob.isPersistenceRequired or Mob.requiresCustomPersistence — among them a
mob name-tagged, holding loot it picked up, leashed or riding something — so a
named zombie
costs nothing against either cap. That is the same predicate pair that keeps
Mob.checkDespawn from discarding it by distance, which is why name it and it
stays and name it and it stops counting are one fact and not two.
The spawn distances, and one that is not the number it looks like
NaturalSpawner names three spawn distances — NaturalSpawner.MIN_SPAWN_DISTANCE
24, NaturalSpawner.SPAWN_DISTANCE_CHUNK 8 and
NaturalSpawner.SPAWN_DISTANCE_BLOCK 128 — and the two tests against a player
compare squared distances, against 576 and 16384. A fourth, NaturalSpawner.INSCRIBED_SQUARE_SPAWN_DISTANCE_CHUNK, is neither 8
nor 24: it is the floor of 8 divided by the square root of two, so 5, and
DistanceManager.hasPlayersNearby uses it as the fast yes of a three-way
answer — inside 5 chunks certainly near, beyond 8 certainly not, and in
between fall through to the real per-player distance test.
What finalizeSpawn settles for the whole pack
Mob.finalizeSpawn adds a triangular random bonus to Attributes.FOLLOW_RANGE
under Mob.RANDOM_SPAWN_BONUS_ID and rolls a 5 % chance of left-handedness.
Zombie.finalizeSpawn then rolls loot-pickup and door-breaking against local
difficulty, equipment and its enchantments, and — the part players notice —
returns a Zombie.ZombieGroupData that the loop feeds back into the next
mob of the same group. Baby-or-adult is decided once, by the first zombie, and
inherited by the rest: a spawn group is all-baby or all-adult, never mixed. A
baby gets a 5 % roll at an existing unridden Chicken within five blocks
out on each side and three up or down, and only if that roll fails a second 5 % roll
to create one.
Two different limits can end a pack. Mob.isMaxGroupSizeReached breaks the
current group and lets the next of the three attempts start, though only a
tropical fish that does not school ever answers yes, so a zombie’s group ends
when its tries run out; Mob.getMaxSpawnClusterSize returns outright and
kills all three. Mob’s base value is four, and seven
classes override it: AbstractHorse to 6, Wolf and AbstractFish to 8 —
with AbstractSchoolingFish overriding again to return its own school size —
and Ghast, HappyGhast and Pillager down to 1.
The variant that same method picks
Seven species pick a variant in their own override of Mob.finalizeSpawn
— Chicken, Cow, Pig, Cat, Frog, Wolf and ZombieNautilus — and
all seven do it through the same call,
VariantUtils.selectVariantToSpawn, handed a SpawnContext built from the level and the block position. Each
variant in the registry carries a SpawnPrioritySelectors: a list of
conditions, each with an integer priority. PriorityProvider.select unpacks
every variant’s selectors into one list, sorts it by priority descending, and
walks it keeping entries whose condition passes and dropping every entry below
the highest priority that has matched — so the highest matching priority wins
outright, and PriorityProvider.pick then chooses uniformly at random among
whatever is tied at it. A variant whose selector has no condition at all
(PriorityProvider.alwaysTrue, which SpawnPrioritySelectors.fallback wraps)
always matches, which is how a default loses to a biome-specific variant
without either knowing the other exists. SpawnConditions registers three
condition types into BuiltInRegistries.SPAWN_CONDITION_TYPE — BiomeCheck,
StructureCheck and MoonBrightnessCheck — and the shipped data uses the
priority to mean rarity: an all-black cat is priority 1 inside a swamp hut
and priority 0 anywhere the moon is at least 0.9 bright, so inside a hut it
always wins, and under a full moon it only joins the other ten variants in an
even draw. The registry of codecs behind one interface
is the data-driven type pattern once
more, and this is the entry that pattern’s table sends here for.
The other ways in
Natural spawning is one caller of LevelWriter.addFreshEntity among many.
BaseSpawner drives the SpawnerBlockEntity and TrialSpawner the trial
chambers (block entities
owns the block half of both); SpawnEggItem and
SummonCommand are the deliberate ones; AgeableMob.getBreedOffspring makes
babies; a raid spawns its waves through Raid and its Raiders; and five
CustomSpawner implementations — PhantomSpawner,
PatrolSpawner, CatSpawner, WanderingTraderSpawner and VillageSiege —
are ticked as a list by ServerLevel.tickCustomSpawners after the chunks,
a list only the overworld is constructed with
(the level tick).
Each stamps one of the nineteen EntitySpawnReason constants, though not a
distinct one — phantoms and cats both count as natural, sieges and
wandering traders both as event — and that reason never leaves the server: nothing about why something spawned
crosses the wire. Nor does all of it change anything: six of the nineteen are
labels no class tests. Of the thirteen that are tested, eleven are compared
directly, nearly always in a species’ own override of Mob.finalizeSpawn, and
the two spawner reasons only through EntitySpawnReason.isSpawner and
EntitySpawnReason.ignoresLightRequirements, in the spawn rules
(entity spawn reasons lists every site).
Entry: what addFreshEntity actually does
LevelWriter.addFreshEntity is a default method that returns false.
Level does not override it and neither does ClientLevel. Exactly two
classes do. ServerLevel.addFreshEntity is the one this page is about.
WorldGenRegion.addFreshEntity is the other, and it does something entirely
different: it writes the entity straight into the ChunkAccess’s own list and
never touches PersistentEntitySectionManager at all. That is the path of
EntitySpawnReason.CHUNK_GENERATION, and of EntitySpawnReason.STRUCTURE for
the mobs a structure piece places — worldgen mobs are parked in the
proto-chunk as NBT and only enter the manager later, when the chunk is
promoted and PersistentEntitySectionManager.addWorldGenChunkEntities is
handed them (the generation pipeline).
On the client the only way in is ClientLevel.addEntity, called from the
packet handler, and it begins by removing whatever already holds that
network id. The server door takes the passengers with it:
ServerLevelAccessor.addFreshEntityWithPassengers walks Entity.getSelfAndPassengers,
vehicle first, and puts each of them through the four steps below — all four on
one tick, and the order of them is the thing to read.
sequenceDiagram
participant SL as ServerLevel
participant PESM as PersistentEntity<br/>SectionManager
participant CM as ChunkMap
participant ETL as EntityTickList
participant Wire as the network
Note over SL,Wire: all of it on the tick it is created
SL->>SL: addFreshEntity, once per body, the vehicle first
SL->>PESM: addNewEntity
PESM->>PESM: claim the UUID, file it in its EntitySection, install the Callback
PESM->>SL: LevelCallback.onCreated
PESM->>SL: LevelCallback.onTrackingStart
SL->>CM: addEntity, through ServerChunkCache
CM->>Wire: ClientboundAddEntityPacket, with data, attributes, equipment and leash
PESM->>SL: LevelCallback.onTickingStart
SL->>ETL: add
The manager files the entity before anything else hears of it, and the client is told in the middle: tracking, then the packet, then ticking. Leaving and being written are the other half, and they belong to the state machine below.
PersistentEntitySectionManager.addNewEntity is the manager’s one public step
in that figure, and LevelCallback.onCreated the first thing it raises.
ServerLevel.EntityCallbacks is the class those callbacks land in, and the
figure draws every call through it: PersistentEntitySectionManager.startTracking
and PersistentEntitySectionManager.startTicking are private, and apart from
the first of them filing the entity in the manager’s EntityLookup, all they
do is raise LevelCallback.onTrackingStart and LevelCallback.onTickingStart —
two of LevelCallback’s seven — which is how ServerChunkCache.addEntity and
EntityTickList.add come to be called by a class that knows about neither.
ServerLevel.EntityCallbacks is also where a surprising amount
of the level hangs: the scoreboard entry, the
players list and the sleeping-player recount, waypoint tracking, the
navigating-mob set the block-change notifier walks, the EnderDragonPart id
registrations, and the dynamic DynamicGameEventListener registration
(game events).
Findable, ticking, or neither
Findable means findable in the entity grid, which is a second grid: entities
live in EntitySections of 16³ blocks keyed by SectionPos, held by an
EntitySectionStorage that is nothing to do with the block sections of the
same size (chunk anatomy).
A section holds a ClassInstanceMultiMap, so asking a section for every arrow
in it costs one class lookup rather than a walk, and — the part that matters here — a
section carries its own Visibility, which is why status is a property of a
section and not of an entity. Beside it EntityLookup keeps the flat
id-and-UUID index; which of the two a query uses is Part XIII’s
subject.
Visibility is the whole idea in three constants — Visibility.HIDDEN,
Visibility.TRACKED, Visibility.TICKING — and
Visibility.fromFullChunkStatus is the projection: FullChunkStatus.FULL
makes a chunk’s entities findable, FullChunkStatus.ENTITY_TICKING makes them
tick, anything less hides them
(tickets and loading).
stateDiagram-v2
state "Visibility.HIDDEN" as H
state "Visibility.TRACKED" as T
state "Visibility.TICKING" as K
H --> T : reaches FULL, tracking starts
T --> K : reaches ENTITY_TICKING, ticking starts
H --> K : straight up, tracking starts then ticking
K --> T : below ENTITY_TICKING, ticking stops
T --> H : below FULL, tracking stops
K --> H : straight down, ticking stops then tracking
H --> [*] : the manager's unload pass, EntityStorage.storeEntities
These are a section’s states, not an entity’s: an entity other than a player takes its section’s, and changes when that section does or when it walks into another. A jump either way crosses the middle state inside one call, and the exit comes after the client was told, not with it.
What the jumps hide is an order.
PersistentEntitySectionManager.updateChunkStatus runs its four tests in a
fixed order — stop ticking, stop tracking, start tracking, start ticking — so
on the way up an entity becomes trackable before it becomes tickable, and
on the way down it stops ticking before it stops being tracked. Adding and
removing an entity keep the same order. The one path that does not is an
entity walking across a section boundary into a differently-statused section,
which runs through PersistentEntitySectionManager.Callback instead: tracking
first in both directions and then ticking, and
LevelCallback.onSectionChange at the end when the new section is findable. And the always-ticking exemption,
Entity.isAlwaysTicking, which lifts an entity clear of every one of those
filters, is claimed by exactly one class in 26.3: Player.
The tick it gets, and the despawn check it gets anyway
The entity block of ServerLevel.tick is skipped in its entirety once the
level has gone 300 ticks without a ticket that keeps a dimension awake or an
entity arriving from another dimension. Otherwise the tick walks
EntityTickList and, for each entry that is neither removed nor frozen by
TickRateManager.isEntityFrozen, calls Entity.checkDespawn — whose
base implementation is empty, overridden only by Mob, EnderDragon,
WitherBoss and ShulkerBullet, so for an item or an arrow it is a no-op
call. Only after that does the range test run: a ServerPlayer is exempt
outright, everything else needs DistanceManager.inEntityTickingRange for its
own chunk, and an entity already riding a live vehicle returns here to be
ticked by ServerLevel.tickPassenger instead.
So despawn is checked for every live, unfrozen member of the tick list while
ticking is not.
The two sets should agree, and mostly do — the tick list is the ticking set
— but they read different sources: membership follows the chunk holder’s
promoted FullChunkStatus, while the per-tick gate reads the simulation
tracker directly, and the two need not have converged in the same tick.
EntityTickList is what makes that walk safe. It holds two id-keyed maps and
a nullable reference to whichever one is being iterated. The first add or
remove during a walk copies the live map into the spare and swaps them, so
the in-flight iterator finishes over the original, untouched map and the
mutation lands in the new one; every later one in the same walk writes to the
new map directly. A second concurrent walk is refused outright, by
checking that reference rather than a boolean.
Ending one: Mob.checkDespawn
Left in a loaded chunk, the zombie ends through Mob.checkDespawn, whose
first branch consults no player at all: on Peaceful, anything whose type is
not EntityType.isAllowedInPeaceful is discarded on the spot, ahead of even
the persistence check. Past that, persistence does not end the method: it
only blocks the two discards below, and a named zombie’s
LivingEntity.noActionTime still resets whenever a player comes near.
The rest is measured against the nearest non-spectator player — and if
there is no player in the level at all, both remaining branches do nothing, so
a mob alone in a world never despawns by distance. Beyond
MobCategory.getDespawnDistance — 128 blocks for every category except
MobCategory.WATER_AMBIENT, which is 64 — it is discarded instantly. Beyond
MobCategory.getNoDespawnDistance, a flat 32 for every category, it is
discarded on a 1-in-800 roll, but only once LivingEntity.noActionTime has
passed 600; inside that 32 the same method resets that counter to zero, so
standing near a mob keeps it alive. Both distance branches also require
Mob.removeWhenFarAway, the per-species veto — and it is broader than people
expect: Animal returns false, which only eight classes below it override (a
hoglin, a chicken carrying a jockey and an untamed cat among them), and
Villager returns false for every villager, so a wild cow on a hilltop never
despawns by distance at all. That is why 128 blocks and it is gone is a species-dependent
rule and not a universal one. What both branches
call is Entity.discard, which destroys and does not save.
Ending two: the chunk goes away
Walk far enough instead and the chunk falls out of entity-ticking: the zombie
stops ticking but stays findable. Fall to Visibility.HIDDEN and two things
happen, one after the other. At the status change,
PersistentEntitySectionManager.updateChunkStatus stops ticking and stops
tracking the section’s entities, and stopping tracking is what reaches
ChunkMap and sends ClientboundRemoveEntitiesPacket — the client is told
then, not at the write. The chunk key goes into the manager’s unload set,
and the manager’s next PersistentEntitySectionManager.tick — later in the
same server tick, or in the next — runs
PersistentEntitySectionManager.processUnloads over it.
That later step is not a formality, and it can refuse. A chunk whose entity
data is still being read back off disk is deferred to a future tick. A chunk
that has entities to save but has never been read is loaded first, so
the two sets can be merged — the unload triggers a load. Only then does
EntityStorage.storeEntities write the Entities list and a Position into
the entities/ region files (chunk storage),
which are separate from the block region/ files and which remember the
chunks that came back empty so they are never re-read. Each saved entity and
its passengers then take Entity.RemovalReason.UNLOADED_TO_CHUNK and drop
their level callback.
Entity.shouldBeSaved has three clauses, and past two overrides that refuse
outright — Player, which is saved in its own file, and EnderDragonPart —
they decide the whole contents of that file, bar the types built
EntityType.Builder.noSave, which pass them and write nothing. Passengers are
written inside their vehicle, never beside it, so anything currently riding
is refused; a vehicle carrying exactly one player among its passengers is
refused too, because it travels in that player’s own data instead; and
— the clause that is easy to miss, because it is the first one in the method —
an entity already carrying a non-saving removal reason is skipped, which is what
keeps a discarded mob still sitting in a section out of the file.
Five reasons, one label
| reason | destroys | saves | what leaves it behind |
|---|---|---|---|
Entity.RemovalReason.KILLED | yes | no | death, in every sense the game means it |
Entity.RemovalReason.DISCARDED | yes | no | Entity.discard, every despawn, a ConversionType.SINGLE conversion (Mob.convertTo adds the new mob, then discards the old — ConversionType.SPLIT_ON_DEATH keeps it), the client replacing a network id |
Entity.RemovalReason.UNLOADED_TO_CHUNK | no | yes | the unload above — the only reason that saves |
Entity.RemovalReason.UNLOADED_WITH_PLAYER | no | no | a player leaving, and the vehicle and ender pearls that travel in its own save data |
Entity.RemovalReason.CHANGED_DIMENSION | no | no | a change of dimension, where the entity is rebuilt on the far side — a player only on its way out of the End |
Destroys means LevelCallback.onDestroyed fires — the scoreboard entry and
the waypoint go. An unloading zombie keeps all of it, because
Entity.RemovalReason.UNLOADED_TO_CHUNK does not destroy. The five are not a
state machine, and Entity.setRemoved is where that shows: it writes the
reason only if none is set, so the entity’s stored reason is the first
one and a second call cannot change it — but the rest of the method runs every
time, and it runs on two different reasons at once. The dismount branch reads
the stored reason (it dismounts from a vehicle only when that reason destroys);
passengers are dropped unconditionally; and
EntityInLevelCallback.onRemove is then fired with the reason this call was
given, which on a second call is not the one the entity is carrying. The one link a removal
does not break is an EntityReference somebody else is holding (entity
anatomy),
which decays back to a UUID rather than to nothing.
Where to look
NaturalSpawner is one file of static methods and the cascade above is its
table of contents; read NaturalSpawner.spawnCategoryForPosition first, then
NaturalSpawner.SpawnState and LocalMobCapCalculator for the two caps and
SpawnPlacements for the per-type rules the cascade consults.
Mob.finalizeSpawn is where a group becomes a group and
Mob.checkDespawn where one ends. For the other half of the page, start at
PersistentEntitySectionManager.updateChunkStatus — the four tests in their
fixed order — with Visibility and EntityTickList beside it, and finish on
Entity.setRemoved and Entity.RemovalReason. Two doors the page leaves
closed: EntityLookup, the flat index a command selector walks when it has no
area to search, and TransientEntitySectionManager, the client’s much simpler
counterpart to everything above.
Before this page: authority,
on which side is allowed to decide any of it. After it: synched entity
data —
what the ClientboundAddEntityPacket bundle above is carrying, and how it
stays current.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Synched entity data
Verified against Minecraft 26.3 · Part VI · A player shears a sheep: one byte flips on the server, and the wool disappears on every screen in tracking range.
You right-click a sheep with shears. Somewhere on the server a single byte
changes — bit four of one entry in a nineteen-slot array the sheep carries —
and before that tick ends, every player watching has been told, by a packet
that spends four bytes after the entity id. That array is SynchedEntityData,
the channel the server uses to describe an entity to the clients that see it: the
health bar over another player, a sneaking crouch, an armour stand’s pose,
an item frame’s item, this sheep’s wool. It is a numbered array, and the
numbers are the surprising part. The slot the wool lives in is not written
anywhere in Sheep: it is 18 because eighteen slots were handed out above
Sheep in its superclass chain, and one new field on Entity would renumber
every entity in the game. Ids are ordinals handed out down the class tree
by a single shared ClassTreeIdRegistry, which walks only the superclass
chain: Entity takes 0 to 7, LivingEntity 8 to 14, Mob 15, AgeableMob
16 and 17, and Sheep, last in the chain, gets 18.
Nothing names these numbers, nothing writes them down, and they stop at 254
— because on the wire, 255 means end of packet.
The cast
| class | what it decides | thread |
|---|---|---|
SynchedEntityData | one entity’s numbered array, and whether anything in it has changed since the last flush | one container per side, a live one confined to the Server thread or the Render thread |
SynchedEntityData.DataItem | one slot: its value, the default it was built with, and its own dirty flag | with its container |
EntityDataAccessor | the key — an int id and a serializer, equal to another accessor on the id alone | immutable, shared by every instance of the class |
ClassTreeIdRegistry | which id a SynchedEntityData.defineId call gets, from the last id already taken by an ancestor class | whichever thread first initialises the class |
EntityDataSerializers | the 44 registered serializers and the wire id of each, in registration order | a static block, once |
ServerEntity | whether this entity sends anything this tick, and what | the Server thread |
ClientboundSetEntityDataPacket | the wire form: an entity id, then id/serializer/value triples, then 255 | encoded on the Netty pipeline, built on the Server thread |
ClientPacketListener | applying an incoming batch to the client’s own container | the Render thread |
Nineteen slots, and where the numbers come from
SynchedEntityData.defineId is called from the static initialiser of an
entity class and asks ClassTreeIdRegistry.define for a number.
ClassTreeIdRegistry keeps one map from class to last id issued, and
ClassTreeIdRegistry.getLastIdFor walks up the superclass chain until it
finds an entry — so a subclass continues its parent’s numbering rather than
starting over. Java’s guarantee that a superclass initialises before its
subclass is the entire ordering mechanism. ClassTreeIdRegistry.getCount is
the same walk plus one, and it is what sizes the array.
A Sheep — Entity → LivingEntity → Mob → PathfinderMob →
AgeableMob → Animal → Sheep, of which PathfinderMob and Animal
define nothing — therefore has exactly nineteen slots:
| id | field | serializer | default |
|---|---|---|---|
| 0 | Entity.DATA_SHARED_FLAGS_ID | EntityDataSerializers.BYTE | 0 |
| 1 | Entity.DATA_AIR_SUPPLY_ID | EntityDataSerializers.INT | Entity.getMaxAirSupply |
| 2 | Entity.DATA_CUSTOM_NAME | EntityDataSerializers.OPTIONAL_COMPONENT | empty |
| 3 | Entity.DATA_CUSTOM_NAME_VISIBLE | EntityDataSerializers.BOOLEAN | false |
| 4 | Entity.DATA_SILENT | EntityDataSerializers.BOOLEAN | false |
| 5 | Entity.DATA_NO_GRAVITY | EntityDataSerializers.BOOLEAN | false |
| 6 | Entity.DATA_POSE | EntityDataSerializers.POSE | Pose.STANDING |
| 7 | Entity.DATA_TICKS_FROZEN | EntityDataSerializers.INT | 0 |
| 8 | LivingEntity.DATA_LIVING_ENTITY_FLAGS | EntityDataSerializers.BYTE | 0 |
| 9 | LivingEntity.DATA_HEALTH_ID | EntityDataSerializers.FLOAT | 1.0 |
| 10 | LivingEntity.DATA_EFFECT_PARTICLES | EntityDataSerializers.PARTICLES | empty |
| 11 | LivingEntity.DATA_EFFECT_AMBIENCE_ID | EntityDataSerializers.BOOLEAN | false |
| 12 | LivingEntity.DATA_ARROW_COUNT_ID | EntityDataSerializers.INT | 0 |
| 13 | LivingEntity.DATA_STINGER_COUNT_ID | EntityDataSerializers.INT | 0 |
| 14 | LivingEntity.SLEEPING_POS_ID | EntityDataSerializers.OPTIONAL_BLOCK_POS | empty |
| 15 | Mob.DATA_MOB_FLAGS_ID | EntityDataSerializers.BYTE | 0 |
| 16 | AgeableMob.DATA_BABY_ID | EntityDataSerializers.BOOLEAN | false |
| 17 | AgeableMob.AGE_LOCKED | EntityDataSerializers.BOOLEAN | false |
| 18 | Sheep.DATA_WOOL_ID | EntityDataSerializers.BYTE | 0 |
There is one of those arrays per side, not one per entity: the server’s
Sheep has its own container and your client’s Sheep has another, built
independently from the same class chain, and the packet below is the only thing
that ever reconciles them.
Declaration order is not definition order
The declaration order and the definition order are two different lists.
Ids come from where SynchedEntityData.defineId sits in the class body; the
values come from the Entity constructor, which defines its own eight and
then calls the abstract Entity.defineSynchedData that every subclass with
data of its own overrides and chains up through. LivingEntity defines its seven in a
different order from the one that numbered them, and it does not matter,
because SynchedEntityData.Builder.define writes each item at
EntityDataAccessor.id. SynchedEntityData.Builder.build then refuses to
hand over a container with any slot still null, naming the id it is missing —
which is why SynchedEntityData.get can read a slot with no null check at
all.
Four slots that are bitfields
Four of the nineteen are bitfields, and they are the dense part of the
channel. Slot 0 is Entity.FLAG_ONFIRE 0, Entity.FLAG_SHIFT_KEY_DOWN 1,
Entity.FLAG_SPRINTING 3, Entity.FLAG_SWIMMING 4,
Entity.FLAG_INVISIBLE 5, Entity.FLAG_GLOWING 6 and
Entity.FLAG_FALL_FLYING 7 — bit 2 is unnamed and unused — behind
Entity.getSharedFlag and Entity.setSharedFlag, whose parameter carries a
purpose-built Entity.Flags type-use annotation. Slot 8 carries using an item,
off hand and spin attack (LivingEntity.LIVING_ENTITY_FLAG_IS_USING,
LivingEntity.LIVING_ENTITY_FLAG_OFF_HAND,
LivingEntity.LIVING_ENTITY_FLAG_SPIN_ATTACK), slot 15 no-AI, left-handed
and aggressive behind Mob.setNoAi, Mob.setLeftHanded and
Mob.setAggressive, and slot 18 packs a DyeColor id into the low nibble
and sheared into bit four — Sheep.getColor, Sheep.setColor,
Sheep.isSheared, Sheep.setSheared, and the same storage read out as
DataComponents.SHEEP_COLOR by Sheep.get and written back through
Sheep.applyImplicitComponent.
Nothing may be inserted above you
The numbering belongs to the class, not to the concept. Avatar — the class between LivingEntity and Player
(entity anatomy) — owns
Avatar.DATA_PLAYER_MAIN_HAND and Avatar.DATA_PLAYER_MODE_CUSTOMISATION,
so the skin-part toggles belong to every avatar, while
Player.DATA_PLAYER_ABSORPTION_ID, Player.DATA_SCORE_ID and the two
shoulder parrots (Player.DATA_SHOULDER_PARROT_LEFT,
Player.DATA_SHOULDER_PARROT_RIGHT, optional ints rather than NBT) sit one
level further down.
That is the whole answer to can two mods both add a field to LivingEntity:
only by accident. Inserting, removing or reordering a
SynchedEntityData.defineId on a base class shifts every id below it in every
subclass, so two mods claiming the same number either collide in
SynchedEntityData.Builder.define, which rejects a duplicate, or land a value
in the wrong slot, which throws the serializer check on the client when the
two serializers differ and passes silently when they match. And one class
treats these numbers as what they are:
Display.RENDER_STATE_IDS is an int set of the eight accessor ids that
force a render-state rebuild, tested against each incoming accessor, with
Display.TextDisplay keeping a second set of its own. Nothing in the codebase
demonstrates more plainly that a slot number is an ordinal and not a name.
The serializer is the other half of the key
An EntityDataAccessor is an id and an EntityDataSerializer, and the
serializer is what turns the value into bytes. The interface is deliberately
thin: a StreamCodec returned by EntityDataSerializer.codec — returned,
not extended — paired with an EntityDataSerializer.copy that copies a value
as it is packed, so a later change to the container’s live value cannot reach
a packet already built or the tracker’s snapshot.
EntityDataSerializer.ForValueType is the immutable case, where copying is
identity, and EntityDataSerializer.forValueType builds one from a codec
alone.
EntityDataSerializers registers 44 of them into a
CrudeIncrementalIntIdentityHashBiMap, from a single static block, so
registration order is the wire id — EntityDataSerializers.BYTE is 0,
EntityDataSerializers.POSE is 20, EntityDataSerializers.DYE_COLOR is
43 and last. Several of them carry Holders of data-pack registries — the
per-species variants and the painting variants — and those, the item stacks,
the text components and the particles are why the buffer on both sides is a
RegistryFriendlyByteBuf rather than a plain
one (codecs). The full list with wire
ids and value types is the serializer table.
EntityDataSerializers.registerSerializer is public and is the only thing
that fills the bimap: vanilla calls it 44 times from that one block, and
nothing else in the tree ever calls it again. It is a mod extension point
shipped with no caller outside its own file.
Shears to wool gone, inside one tick
sequenceDiagram
participant MPGM as MultiPlayer<br/>GameMode
participant SGPL as ServerGamePacket<br/>ListenerImpl
participant Sheep as Sheep
participant SED as SynchedEntityData
MPGM->>SGPL: ServerboundInteractPacket, from MultiPlayerGameMode.interact
MPGM->>MPGM: then the same interaction locally, unless spectator
rect rgba(0, 0, 0, 0.04)
Note over SGPL,SED: the server tick, before MinecraftServer.tickServer
SGPL->>SGPL: Entity.setShiftKeyDown, then the range and border checks
SGPL->>Sheep: Mob.interact, down from Player.interactOn
Sheep->>Sheep: Mob.checkAndHandle<br/>ImportantInteractions
Sheep->>Sheep: Sheep.mobInteract
Sheep->>Sheep: Sheep.shear: the sound, the loot table, then Sheep.setSheared
Sheep->>SED: SynchedEntityData.set, slot 18 with bit four
SED->>Sheep: Entity.onSyncedDataUpdated, before anything is dirty
SED->>SED: SynchedEntityData.<br/>DataItem.setDirty
end
The server’s container, and the one moment worth slowing down for: the
callback to Sheep runs first and the item is marked dirty after it, not the
other way round. The whole band is one tick, and it is over before
MinecraftServer.tickServer has begun.
Later in that same tick the container is drained, and what the client writes it into is a second container — one per side, not one per entity:
sequenceDiagram
box transparent the server
participant SED as SynchedEntityData
participant SE as ServerEntity
end
box transparent the client
participant CPL as ClientPacketListener
participant CSED as SynchedEntityData
end
rect rgba(0, 0, 0, 0.04)
Note over SED,SE: the same tick, the ServerLevel.chunkSource phase
SE->>SED: SynchedEntityData.packDirty, once the gate opens
SED-->>SE: one SynchedEntityData.DataValue: id 18, serializer 0, one byte
SE->>CPL: ClientboundSetEntityDataPacket, flushed at the end of the tick
end
CPL->>CSED: SynchedEntityData.assignValues, per item then the batch
Note over CPL,CSED: next frame: SheepRenderer.extractRenderState reads Sheep.isSheared
Two containers, one per side, and the packet is the only thing that ever
reconciles them. The server’s half runs later in the same tick as the shear,
in the chunk-source phase; the client’s container was built with the client’s
own Sheep long before, and changes only when the packet lands.
The click. MultiPlayerGameMode.interact sends a
ServerboundInteractPacket — a flat record of entity id, hand, an
entity-relative hit location and the secondary-action flag, attacks having
left for ServerboundAttackPacket — and then, on the next line, runs the
interaction locally as a prediction, unless the local game mode is spectator.
The packet goes first.
The server checks the geometry, not the outcome.
ServerGamePacketListenerImpl.handleInteract confirms the thread with
PacketUtils.ensureRunningOnSameThread and that the client has loaded,
resolves the entity with ServerLevel.getEntityOrPart, tests the world
border and Player.isWithinEntityInteractionRange, and checks the held item
against the level’s feature flags. Before any of the geometry, though, it
writes the packet’s secondary-action flag straight into
Entity.setShiftKeyDown — which is itself a synched-data write on the
player, so every interaction packet is also a potential update on slot 0.
Dispatch down the hierarchy. Player.interactOn calls Entity.interact,
which dispatches virtually to the most derived override, Mob.interact.
That runs three things in a fixed order: Mob.checkAndHandleImportantInteractions
(name tags, spawn eggs), then the superclass hook Entity.interact with its
leashing branch, and only if that passes, Sheep.mobInteract — the base
hook runs between the two mob hooks, not before them. The shears test is
item identity against Items.SHEARS, not a tag and not a component, plus
Sheep.readyForShearing from the shared Shearable interface, whose other
implementors are MushroomCow, SnowGolem, Bogged, CopperGolem and
SulfurCube. A sheep that is not ready returns InteractionResult.CONSUME
rather than falling through, which is why shears on an already-sheared sheep
do nothing visible at all.
The effect, and one byte. Sheep.shear plays SoundEvents.SHEEP_SHEAR,
drops wool through LivingEntity.dropFromShearingLootTable with
BuiltInLootTables.SHEAR_SHEEP — the tool is passed in, so the loot table
can see it — and calls Sheep.setSheared, which ors bit four into slot 18.
SynchedEntityData.set compares the new value against the current one,
stores it, calls Entity.onSyncedDataUpdated for that accessor, and then
marks the item and the container dirty. Sheep does not override the hook,
and the base implementation reacts to exactly one accessor, Entity.DATA_POSE,
by calling Entity.refreshDimensions — which is also the first answer to
whether any of this channel touches the physics the client simulates. A few
slots do. Entity.DATA_POSE travels on its own EntityDataSerializers.POSE,
and the hook resizes the hitbox on whichever side just received it (entity
anatomy has what the resize
costs); a dozen subclasses resize the same way on a value of their own, this
sheep’s AgeableMob.DATA_BABY_ID among them; Entity.DATA_NO_GRAVITY
switches gravity off; and a handful more steer what the client simulates, a
pig’s boost, a sleeper’s bed, an item in use slowing your own input. The rest
of the channel is cosmetic to the client, or read back by gameplay code that
already knew. Back in Sheep.mobInteract:
Entity.gameEvent with GameEvent.SHEAR for the sculk listeners
(game events),
ItemStack.hurtAndBreak on the shears, and InteractionResult.SUCCESS_SERVER.
The send, in the same tick. MinecraftServer.processPacketsAndTick
drains the queue with PacketProcessor.processQueuedPackets and only then
calls MinecraftServer.tickServer, so the shear happened before the tick
proper began. ServerLevel.tick reaches its chunkSource phase after
block and fluid ticks and before block events and entity ticking, and that
phase runs ChunkMap.tick, whose loop over ChunkMap.TrackedEntity is the
only caller of ServerEntity.sendChanges in the tree. The dirty flag opens
the gate, ServerEntity.sendDirtyEntityData calls SynchedEntityData.packDirty,
and one ClientboundSetEntityDataPacket goes to every tracking player, and
to the entity itself when it is a player. After the entity id, the wire carries an unsigned byte
18, a var-int 0 for EntityDataSerializers.BYTE, one payload byte and then
the terminator 255 — and that terminator is the whole reason ids stop at
254: the 255 ClientboundSetEntityDataPacket.EOF_MARKER names, and the 254
SynchedEntityData.MAX_ID_VALUE names.
The packet is not on the wire yet. MinecraftServer.tickChildren calls
ServerCommonPacketListenerImpl.suspendFlushing on every player before it
ticks any level and ServerCommonPacketListenerImpl.resumeFlushing at the
end of the tick, so a whole tick’s packets leave together.
The apply, and the frame. ClientPacketListener.handleSetEntityData
looks the entity up in ClientLevel and silently drops the entire packet if
the id is unknown, then calls SynchedEntityData.assignValues, which checks
that the incoming serializer is the one the accessor was defined with — a
mismatch throws, loudly, on the client — stores each value, fires
Entity.onSyncedDataUpdated per item and then the batch overload once. That
batch overload, SyncedDataHolder.onSyncedDataUpdated, is the only place a
client could see a whole update atomically, and nothing in the game overrides
Entity’s empty body of it.
Nothing tells the renderer. It finds out next frame, by reading the sheep
again from
scratch:
SheepRenderer.extractRenderState copies Sheep.isSheared and
Sheep.getColor into a fresh SheepRenderState, after which SheepWoolLayer
draws nothing. The
wool vanishing is a layer skipped, not a model swap — and only one layer,
because SheepWoolUndercoatLayer tests colour, baby and invisibility but
never the sheared flag, so a sheared coloured sheep still draws its undercoat.
The gate that holds a packet back
Two tests stand between a dirty byte and the wire, and only the first can hold it back:
flowchart TD
IN{"ChunkMap.tick: a section changed, Entity.needsSync, or in entity-ticking range?"}
SC["ServerEntity.sendChanges, opening with Entity.updateDataBeforeSync"]
GATE{"the tracker's UpdateInterval, Entity.needsSync, or SynchedEntityData.isDirty?"}
SEND["position, rotation and motion"]
DATA["ServerEntity.sendDirtyEntityData"]
HOLD["nothing goes out, and the dirty flags survive the tick"]
QUIET["no position this tick, and no data was waiting"]
IN -- "no" --> HOLD
IN -- "yes" --> SC
SC -- "an ItemFrame, every tenth tick: the map bypass" --> DATA
SC --> GATE
GATE -- "yes" --> SEND
SEND --> DATA
GATE -- "no" --> QUIET
Two tests, and the one thing that gets past the second without answering it. A dirty container is one of the second test’s own terms, so only the first can hold a byte back, and what it holds is kept for a later tick, not lost.
The first is not this
page’s: ChunkMap.tick decides whether ServerEntity.sendChanges is called
at all, on three conditions what the client is
told
sets out. An entity that fails them keeps its dirty data until one of the
three becomes true. Entity.needsSync is the entity’s own way of forcing the
issue: a flag that a push, a load and twenty-one other classes set
to say ask about me this tick whatever the schedule says, read by both tests.
The second test lets a dirty byte through, since SynchedEntityData.isDirty is
one of its terms. Inside ServerEntity.sendChanges, that gate
covers the position block and the usual
ServerEntity.sendDirtyEntityData call, which is why shearing a sheep can
also send that sheep’s position and rotation this tick: synched data is,
incidentally, a latency channel for movement.
The interval comes from EntityType.updateInterval, fixed on the type at
registration and turned into an UpdateInterval when ChunkMap starts
tracking the entity — 2 for a player, 3 by default, and Integer.MAX_VALUE for
eight types including EntityTypes.ITEM_FRAME, which becomes
UpdateInterval.NEVER, so their interval branch never fires at all (entity
anatomy has the
eight and the tracking range beside it).
ServerEntity.sendChanges also has an ItemFrame special case that runs
every tenth tick before the gate: it sends a framed map’s own update packets,
which a player not carrying that map gets no other way, and then calls ServerEntity.sendDirtyEntityData,
the one call to the synched-data flush outside the gate. (Two sends also sit outside the
gate — the passengers packet and the Entity.syncVelocity motion packet — but
neither touches the data channel.)
ServerEntity.handleMinecartPosRot calls it too, but from inside the gate,
not around it. Entity.syncPosition is the other lever. A living entity’s
SteppedInterpolationTracker (every living type’s but a shulker’s) takes the
flag first and records the position as a step; for the rest it realigns the
tracker’s own counter so the very next evaluation lands on a multiple of the
interval, which does nothing for a type whose interval is
UpdateInterval.NEVER.
Entity.updateDataBeforeSync opens ServerEntity.sendChanges, ahead of the
gate, and it is the hook LivingEntity overrides to reconcile its effects:
LivingEntity.updateInvisibilityStatus and the glowing status write slot 0,
and the swirl list goes into slot 10. A mob effect that expired this tick can
therefore dirty the container and open its own gate, in the same call that
goes on to read the flag.
One flush carries at most one value per slot, because
SynchedEntityData.packDirty clears each flag as it packs — but the comparison
in SynchedEntityData.set is against the current value, not the last one
sent, so setting A then B then A within a tick dirties the item twice and then
sends A, a value the client already had. The only thing that never dirties is
setting a slot to what it already holds, and even that can be overridden:
SynchedEntityData.set has a three-argument force-dirty form that skips the
comparison. Display uses it to restart an interpolation whose delay was
re-set to the same number; CopperGolem’s two uses of it are redundant,
because the value it writes is always the previous weather state and so always
different.
Five more channels, all keyed by the same entity id
Synched data is one of six clientbound descriptions of an entity, and
knowing which one a fact travels on answers most why does the client not
know that questions. There is no serverbound counterpart to any of them.
Nothing stops SynchedEntityData.set on the client — LocalPlayer prediction
does it constantly — but the write goes nowhere:
ServerEntity.sendDirtyEntityData is the only caller of
SynchedEntityData.packDirty in the whole tree, so the client’s dirty flag is
read by nobody and the next server value overwrites the slot
(authority).
One serverbound packet moves two slots by proxy all the same:
ServerboundClientInformationPacket reaches
ServerPlayer.updateOptions, which sets the skin-customisation byte and the
main hand. And the client’s container is not useless to it —
ClientPacketListener.handleRespawn copies the old player’s non-default values
straight into the new one when the respawn keeps entity data, the single
client-to-client use of the channel.
| channel | packets | note |
|---|---|---|
| synched data | ClientboundSetEntityDataPacket | to trackers and self, when the entity is a player — ServerEntity.Synchronizer.sendToTrackingPlayersAndSelf |
| attributes | ClientboundUpdateAttributesPacket | flushed by the same ServerEntity.sendDirtyEntityData (attributes) |
| equipment | ClientboundSetEquipmentPacket | incremental updates bypass ServerEntity entirely — LivingEntity.handleEquipmentChanges sends them, to trackers only, not the wearer |
| mob effects | ClientboundUpdateMobEffectPacket, ClientboundRemoveMobEffectPacket | the authoritative list, unlike the swirl in LivingEntity.DATA_EFFECT_PARTICLES |
| position and motion | ClientboundMoveEntityPacket, ClientboundEntityPositionSyncPacket, ClientboundRotateHeadPacket, ClientboundSetEntityMotionPacket, ClientboundMoveMinecartPacket | the block the synched-data gate shares; ClientboundTeleportEntityPacket is the exception, sent from Entity itself |
| one-shot events | ClientboundEntityEventPacket | a single byte from ServerLevel.broadcastEntityEvent, dispatched by Entity.handleEntityEvent — EntityEvent declares 64 of them |
Pairing a viewer who has never seen it
Pairing a new viewer runs the same machinery once. ServerEntity.addPairing
calls ServerEntity.sendPairingData, which bundles ClientboundAddEntityPacket
with a ClientboundSetEntityDataPacket, attributes, equipment, passengers and
a leash link into one ClientboundBundlePacket. The data packet is built not
from a fresh pack but from ServerEntity.trackedDataValues, a snapshot taken
in the ServerEntity constructor from SynchedEntityData.getNonDefaultValues
and refreshed only when SynchedEntityData.packDirty later returns something. Two consequences:
an entity still entirely at its defaults sends no data packet on pairing
at all, and a change still waiting behind a closed gate is not in that cache,
so a new viewer is paired with the older snapshot and gets the change with
everyone else on the next flush.
Defaults never travel on pairing, in other words, because
SynchedEntityData.getNonDefaultValues skips any item still equal to its
SynchedEntityData.DataItem.initialValue — which is why a freshly loaded
entity is described by so little. Both sides construct their own defaults
independently from the same class chain, and if they ever disagreed no packet
would correct it.
Where to look
ClassTreeIdRegistry is one short class and it is where the numbering scheme
lives; read it before anything else, then SynchedEntityData itself
and its SynchedEntityData.Builder for how the array is filled and checked.
EntityDataAccessor and EntityDataSerializers are the key and its other
half. On the sending side the whole gate is ServerEntity.sendChanges, with
ServerEntity.sendDirtyEntityData as its exit and ServerEntity.sendPairingData,
reached from ServerEntity.addPairing, as the pairing path; on the receiving
side, ClientPacketListener.handleSetEntityData.
Sheep.mobInteract is the scenario’s own method, three lines of which are this
whole page. One door worth opening: Display, the class that reads accessor
ids as values.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Attributes
Verified against Minecraft 26.3 · Part VI · Strength II is applied to a player: one modifier lands on one attribute, no attribute packet goes on the wire, and the swing three seconds later reads the new number.
Strength II lands on you and thirty seconds later it wears off. In between,
one AttributeModifier — an amount of +6, an operation of
AttributeModifier.Operation.ADD_VALUE, an id of effect.strength — sits on
one attribute of one entity, and every swing you make asks for that attribute
and gets a bigger number back. The same mechanism is behind armour points, a
horse’s jump strength, the extra reach of a creative-mode player and the
distance at which a mob notices you: ask a question, get a number, cheaply,
with a defined order of operations. What is surprising is what does not
happen. Strength II sends no attribute packet at all. The effect itself
goes out, but eight of the forty registered attributes are not client-syncable
and Attributes.ATTACK_DAMAGE is one of them, so for the whole thirty seconds
your own client’s copy of your attack
damage sits at the base value it was born with — 1.0 — and nothing ever tells
it otherwise.
A different system with the same words.
world/attributeis environment attributes — per-position world properties like the sky’s colour (environment attributes and timelines) — with its own registries and its own class also namedAttributeModifier. Nothing on this page refers to it.
The cast
| class | what it decides | thread |
|---|---|---|
Attribute | one named number’s default, its description id, its Attribute.Sentiment (tooltip colour only) and the one boolean that decides whether the client is ever told | built in the Attributes class initialiser, read from every thread after |
RangedAttribute | the minimum and the maximum, and RangedAttribute.sanitizeValue — the clamp. The only subclass, and every registered attribute is one | as above |
AttributeSupplier | what attributes an EntityType has at all, and their base values. Frozen | built at class-init, inside DefaultAttributes |
AttributeMap | which of two dirty sets a change lands in, and therefore whether a packet is sent | the Server thread for mutations, the Render thread for the mirror |
AttributeInstance | the number: a base value, three modifier indices, a dirty flag and a cache | as above |
AttributeModifier | an Identifier, an amount and an operation. A record, and inside an instance the identifier alone is its identity | immutable, shared |
LivingEntity | when the update set drains, and what reacts to a change | both sides, in LivingEntity.tick |
ServerEntity | when the sync set drains and what goes on the wire | the Server thread, in the level tick’s chunkSource phase |
Four objects, two dirty sets, and one list that is neither
flowchart TD
ATTR["Attribute, always a RangedAttribute: a range, and a syncable flag"]
SUP["AttributeSupplier: one frozen prototype map per EntityType"]
MAP["AttributeMap: one per LivingEntity, only what was asked for"]
INST["AttributeInstance: a base value, a cached value, a dirty flag"]
UPD["AttributeMap.attributesToUpdate: every dirtied attribute"]
SYNC["AttributeMap.attributesToSync: only the syncable ones"]
MOD["AttributeMap.onAttributeModified"]
PAIR["AttributeMap.getSyncableAttributes: not a set, a filter"]
REACT["LivingEntity.refreshDirtyAttributes, in the entity's own tick"]
SEND["ServerEntity.sendDirtyEntityData, the ServerLevel.chunkSource phase"]
WIRE["ClientboundUpdateAttributesPacket"]
ATTR -- "registered once" --> SUP
SUP -- "one per EntityType" --> MAP
MAP -- "AttributeMap.getInstance" --> INST
INST -- "AttributeInstance.setDirty" --> MOD
MOD -- "always" --> UPD
MOD -- "and if syncable" --> SYNC
MAP -- "when tracking starts" --> PAIR
UPD --> REACT
SYNC --> SEND
SEND --> WIRE
PAIR --> WIRE
The four objects and the three collections — and every collection is the
AttributeMap’s, which is the thing to look at: the instance only calls
AttributeInstance.setDirty, and the map decides which sets that lands in.
Four objects carry the whole system — the Attribute, the frozen
AttributeSupplier per type, the AttributeMap per entity, and the
AttributeInstance per number asked for. The instance keeps three
lists of its own, and they belong to a later section;
the three in the figure all belong to the map.
Two of them are the dirty sets, and they are not a partition:
AttributeMap.onAttributeModified always adds to the update set and
additionally to the sync set when the attribute is syncable, so a syncable
attribute is in both and a non-syncable one is in the update set alone. The
third is not a dirty set at all:
AttributeMap.getSyncableAttributes filters the whole live map every time it is
asked, and it exists for one caller — ServerEntity.sendPairingData, which has
to describe an entity to a player who has never seen it and so cannot work from
what has changed since last time.
Forty numbers, every one of them clamped
The forty constants of Attributes register themselves in their own static
field initialisers, into BuiltInRegistries.ATTRIBUTE under
Registries.ATTRIBUTE. Attributes.bootstrap does nothing but return
Attributes.MAX_HEALTH, and exists only as the class-loading trigger
BuiltInRegistries needs — which is also why a data pack can reference an
attribute but never add one.
Every one of the forty is a RangedAttribute, and RangedAttribute is the
only subclass of Attribute, so every attribute in the game has a minimum
and a maximum and every computed value passes through one clamp. Several of
those bounds are the reason a mechanic behaves the way it does:
Attributes.MAX_HEALTH has a minimum of 1, so no entity’s maximum health can
reach zero, and Attributes.KNOCKBACK_RESISTANCE has a minimum of −2, so
amplified knockback is a legal value rather than a bug. The full list — id,
constant, default, minimum, maximum, syncable, sentiment — is
the attribute table.
The syncable flag, and the eight that never travel
What has to be said here is the syncable flag, because it explains most of
what a client and a server disagree about. Eight of the forty never reach
the client: Attributes.ATTACK_DAMAGE, Attributes.ATTACK_KNOCKBACK,
Attributes.KNOCKBACK_RESISTANCE, Attributes.FOLLOW_RANGE,
Attributes.TEMPT_RANGE, Attributes.SPAWN_REINFORCEMENTS_CHANCE — whose
registry id is spawn_reinforcements, disagreeing with its own constant name
— and the pair Attributes.WAYPOINT_TRANSMIT_RANGE and
Attributes.WAYPOINT_RECEIVE_RANGE. An attribute is syncable only because
its registration line called Attribute.setSyncable, and that setter is
public and has no freeze behind it, on an object that lives in a registry.
Nothing calls it after bootstrap. Nothing stops it either.
The client is not left with a blank where an unsyncable attribute should be,
which is the part that makes the silence hard to notice: both sides run the
same Attributes class initialiser and build the same DefaultAttributes
prototypes, so your client’s copy of your attack damage holds the prototype’s
1.0 from the moment the entity exists. It is not missing. It is a number that
was right once and is never corrected — and the client reads it: your own
client runs Player.attack for your swing, which asks for your attack damage
(authority).
The prototype, frozen at class-init
DefaultAttributes holds one AttributeSupplier per EntityType, each
built by chaining builders: LivingEntity.createLivingAttributes is the
twenty-six-entry base, Mob.createMobAttributes adds
Attributes.FOLLOW_RANGE at 16, Monster.createMonsterAttributes adds
Attributes.ATTACK_DAMAGE and nothing else, and each species’ own builder
finishes the job. AttributeSupplier.Builder.build keeps the last entry
for a repeated attribute, so a species builder does not have to know what its
ancestors declared: of the five attributes Zombie.createAttributes names,
four are already in the chain and are simply overwritten — attack damage from
the monster builder, follow range from the mob builder, movement speed and
armour from the twenty-six-entry living base — and only
Attributes.SPAWN_REINFORCEMENTS_CHANCE is new. Anything
outside MobCategory.MISC with no supplier at all is logged by
DefaultAttributes.validate.
A prototype is frozen in the sense that AttributeSupplier.Builder.build
arms a callback that throws on any later write: reading one is fine, dirtying
one throws. Reading an attribute the type does not have is also fatal — the
fallback in AttributeSupplier raises rather than returning a default, which
is why LivingEntity.getAttribute is nullable and
LivingEntity.getAttributeValue is not.
Player.createAttributes is on Player, not on Avatar. That intermediate
class (entity anatomy)
owns the player-shaped hitbox but not the attribute set, so Mannequin — the other Avatar —
is registered with LivingEntity.createLivingAttributes and gets the plain
living set, including the registry’s default movement speed of 0.7 rather
than a player’s 0.1. The default is not a dead value: the wandering trader
is registered with the bare Mob builder, which sets no speed either, and
walks on it.
The map, and which set a change lands in
AttributeMap starts empty. AttributeMap.getValue and
AttributeMap.getBaseValue answer from the prototype when the entity has no
instance of its own, and change nothing. AttributeMap.getInstance is
different: it creates the instance on demand through
AttributeSupplier.createInstance, which copies the frozen template with
AttributeInstance.replaceFrom — which ends in AttributeInstance.setDirty.
So asking for an instance is a mutation: the first call to
LivingEntity.getAttribute for a syncable attribute enqueues it for
broadcast before any modifier exists, and a share of the attribute packets a
busy server sends are caused by something merely asking.
The two sets drain in two different phases
The two sets drain in different phases of the same tick, and that is where
the visible lag comes from. ServerEntity.sendDirtyEntityData is reached
from ChunkMap.tick, which runs inside ServerLevel.tick’s chunkSource
phase — before the entities phase (the level
tick). An attribute dirtied during an
entity’s own tick (equipment, an effect, powder snow, anything in
ServerPlayer.updatePlayerAttributes) has therefore already missed this
tick’s send, and a dirty attribute set is not one of the three things that
open ServerEntity.sendChanges’s gate (synched entity
data owns that
gate), so it waits for the next tick whose
count is a multiple of the entity’s EntityType.updateInterval (up to two
ticks for a player, whose interval is 2, and up to three for the default 3),
unless something else opens the gate first. Only a mutation made before the
chunk-source phase — a player’s command, an interaction handled out of the
packet queue at the top of the server tick — can reach the wire in the tick that produced
it, and then only if the gate opens. It is
the same phase ordering that puts a block entity’s writes a tick late
(block entities).
The update set drains in the entity’s own tick — the entities phase for a mob,
the connection phase for a player — in
LivingEntity.refreshDirtyAttributes, which calls
LivingEntity.onAttributeUpdated once per dirtied attribute and then clears
the set. That hook has exactly four branches: clamp health down to a reduced
maximum health, clamp absorption, call Entity.refreshDimensions on a scale
change, and register or unregister the transmitted waypoint with the
ServerWaypointManager. Subclasses add two more —
ServerPlayer.onAttributeUpdated takes the receive half of the waypoint
pair, and Mob.onAttributeUpdated recomputes the pathfinder’s node budget
through PathNavigation.updatePathfinderMaxVisitedNodes on a change to
Attributes.FOLLOW_RANGE or Attributes.TEMPT_RANGE
(pathfinding owns that budget).
LivingEntity.refreshDirtyAttributes is called from LivingEntity.tick with
no side check, so the client runs LivingEntity.onAttributeUpdated too,
clamping health and resizing an entity whose scale changed — which is why the
waypoint branch inside it is the one that has to test for a ServerLevel
explicitly.
A frozen mob is the worked example
Everything above has a cost attached, and the cheapest way to see it is a mob
standing in powder snow. LivingEntity.aiStep calls
LivingEntity.removeFrost and LivingEntity.tryAddFrost back to back,
server-side, with no test for whether anything changed. Each has a gate — the
remove only dirties when the modifier is there, the add needs a
non-air block underfoot and a non-zero frozen counter — but when both hold,
the pair destroys and re-creates a modifier on Attributes.MOVEMENT_SPEED,
dirtying a syncable attribute twenty times a second and re-sending that
entity’s whole movement-speed modifier list for as long as it stays frozen.
Compare ServerPlayer.updatePlayerAttributes, which runs just as often but
uses AttributeInstance.addOrUpdateTransientModifier with a constant modifier
object, and so dirties nothing after the first tick. The whole difference is
whether each write dirties anything at all.
What the map writes to disk
Two more things the map decides, and between them they answer why did my
/attribute change survive death? AttributeMap.pack writes every
instantiated instance, base value included, so a base value set by command
persists with no modifier attached to carry it — AttributeInstance.pack
writes only the permanent modifiers of each. And on respawn,
ServerPlayer.restoreFrom always calls AttributeMap.assignBaseValues but
calls AttributeMap.assignPermanentModifiers only on a full restore:
returning from the End, not an ordinary death. Base values always come
across, a command-added modifier only sometimes.
The instance: three indices and one cached number
An AttributeInstance keeps its modifiers three times over: bucketed by
AttributeModifier.Operation for the arithmetic, indexed by Identifier for
lookup and duplicate detection, and a second id-index of the permanent ones
for saving. A modifier’s identity is its Identifier alone — there is no
UUID and no name — so two systems that pick the same identifier for the same
attribute collide, and AttributeInstance.addTransientModifier and
AttributeInstance.addPermanentModifier throw rather than silently
overwrite. AttributeInstance.addOrUpdateTransientModifier and
AttributeInstance.addOrReplacePermanentModifier are the safe forms. Vanilla
mostly removes by id before it adds, or uses the safe forms; three mob classes
and AttributeCommand guard with AttributeInstance.hasModifier instead.
Transient versus permanent is about keeping: both kinds sit in the
same indices, both affect the value identically, both go on the wire, and
only the permanent ones are packed, or carried across a return from the End. Mob-effect modifiers are added
permanently, and that is the only reason an effect’s modifier survives a
reload (status
effects). On
the client, meanwhile, every modifier is transient, because
ClientPacketListener.handleUpdateAttributes sets the base value, wipes the
whole modifier list and re-adds the incoming ones with
AttributeInstance.addTransientModifier. A client attribute map is never
persisted.
AttributeInstance.getValue recomputes through
AttributeInstance.calculateValue only when the dirty flag is set, and the
flag starts true, so the first read always computes — three passes and one
clamp:
flowchart TD
B["the base value: the prototype's, or whatever has set it since"]
P1["pass 1: every ADD_VALUE modifier, added"]
P2["pass 2: every ADD_MULTIPLIED_BASE modifier, off the same base"]
P3["pass 3: every ADD_MULTIPLIED_TOTAL modifier, off the running total"]
C["RangedAttribute.sanitizeValue, once"]
O["AttributeInstance.cachedValue, to the next AttributeInstance.setDirty"]
B --> P1 --> P2 --> P3 --> C --> O
The three passes, in the order they always run. What separates pass 2 from
pass 3 is the two words on their boxes: the same base against the running total.
That difference is the whole of the arithmetic. Every
AttributeModifier.Operation.ADD_MULTIPLIED_BASE modifier reads the base as pass 1 left it, so two of
them do not compound — +50% and +50% is +100%. Every
AttributeModifier.Operation.ADD_MULTIPLIED_TOTAL modifier reads the previous one’s output, so two of
those do — +50% and +50% is +125%. The clamp runs once at the end:
RangedAttribute.sanitizeValue collapses a not-a-number result to the
minimum and otherwise holds the value between the minimum and the maximum.
Operation order is therefore global, not insertion order, and intermediate
values are never clamped. Within a bucket, iteration order is a hash map’s —
safe only because each bucket’s arithmetic is commutative.
…except in the other implementation, which is insertion-ordered
ItemAttributeModifiers.compute is a second, disagreeing implementation of
the same idea. It walks an item’s ItemAttributeModifiers.Entry list in
declaration order, applying each entry’s operation to the running total as it
goes, with no three-pass grouping at all. It is not a duplicate of
AttributeInstance.calculateValue and it does not agree with it. Its one
caller in the whole game is Mob.getApproximateAttributeWith — the “would
this weapon or armour be better than what I have?” estimate a mob makes when
deciding whether to pick an item up.
Where the modifiers come from
Equipment is the busiest source. LivingEntity.detectEquipmentUpdates, in
the server-only half of LivingEntity.tick, only dispatches;
LivingEntity.collectEquipmentChanges does the work, adding each incoming
stack’s modifiers as transient (removing by id first) through
ItemStack.forEachModifier, which merges
DataComponents.ATTRIBUTE_MODIFIERS with the enchantment modifiers from
EnchantmentHelper.forEachModifier. Exactly eight vanilla enchantments carry
EnchantmentEffectComponents.ATTRIBUTES — fire and blast protection,
respiration, aqua affinity, depth strider, swift sneak, sweeping edge,
efficiency — and EnchantmentAttributeEffect has a second, location-based
path through EnchantmentAttributeEffect.onChangedBlock that exactly one
uses: soul speed, registered under
EnchantmentEffectComponents.LOCATION_CHANGED instead.
The rest, in one breath: MobEffect declarations, permanently and scaled by
amplifier; item components, whose ItemAttributeModifiers.Display decides
whether and how a tooltip line appears — its default form adds the reader’s
own base value back in for Item.BASE_ATTACK_DAMAGE_ID and
Item.BASE_ATTACK_SPEED_ID, so a weapon shows a total rather than a bonus;
SetAttributesFunction in loot tables; Zombie.handleAttributes at spawn;
LivingEntity.setSprinting; ServerPlayer.updatePlayerAttributes; and
AttributeCommand, whose modifier add is permanent, and so saved.
One packet, one direction: ClientboundUpdateAttributesPacket, server to
every tracking player and to the entity itself when it is a player — which is why your own
client has a live attribute map at all. At most 128 attributes fit in one
(checked on encode as well as decode), each snapshot carrying the attribute
holder, the base value and the complete, uncapped modifier list. There is no
serverbound attribute packet.
Strength II lands, and no attribute leaves the server
Put the four objects, the two sets and the two phases together and the whole system runs once, end to end, for a number the wire never hears about:
sequenceDiagram
participant EffC as EffectCommands
participant LE as LivingEntity
participant ME as MobEffect
participant AttrM as AttributeMap
participant AttrI as AttributeInstance
participant SE as ServerEntity
EffC->>LE: addEffect, Strength at amplifier 1
LE->>LE: onEffectAdded, guarded server-side
LE->>ME: addAttributeModifiers, the map and the amplifier
ME->>AttrM: getInstance, Attributes.ATTACK_DAMAGE
AttrM->>AttrI: replaceFrom, inside AttributeSupplier.createInstance, if new
AttrI->>AttrM: onAttributeModified, into the update set, if new
ME->>AttrI: removeModifier, then addPermanentModifier at plus 6
AttrI->>AttrM: onAttributeModified, into the update set
Note over LE,SE: not syncable, so the sync set stays empty
rect rgba(0, 0, 0, 0.04)
Note over LE,SE: the next server tick: the ServerLevel.chunkSource phase, then the player's own tick
SE->>SE: sendDirtyEntityData finds an empty attribute set
LE->>LE: refreshDirtyAttributes drains the update set
end
Note over LE,AttrI: three seconds later, inside Player.attack
LE->>AttrM: getValue, Attributes.ATTACK_DAMAGE
AttrM->>AttrI: getValue, dirty, so calculateValue
AttrI-->>AttrM: 1.0 base, plus the sword's, plus 6.0
AttrM-->>LE: the same number
The lane to watch is ServerEntity: it is reached, finds the attribute sync
set empty, and sends no attribute packet. The effect itself does go out, as a
mob-effect packet when it is added and as the swirl in the entity data, but
the attack damage never does.
MobEffects.STRENGTH is declared with one attribute modifier: +3 on
Attributes.ATTACK_DAMAGE, AttributeModifier.Operation.ADD_VALUE, under
the id effect.strength, and the amount is multiplied by amplifier + 1 when
the modifier is built — so Strength II is +6. (MobEffects.WEAKNESS is
the same construction at −4.) LivingEntity.addEffect puts the instance into
the active list and calls LivingEntity.onEffectAdded, which is guarded
server-side and hands the entity’s AttributeMap to
MobEffect.addAttributeModifiers. If the effect was already running, the
path is LivingEntity.onEffectUpdated instead, which removes and re-adds and,
unlike the add path, refreshes the dirty attributes on the spot.
MobEffect.addAttributeModifiers calls AttributeMap.getInstance, and that
is where the instance for Attributes.ATTACK_DAMAGE is born if nothing has
asked for it yet (a player holding a sword already has one, made when the
sword was equipped), copying the frozen prototype’s base value: 1.0 for a
player, 3.0 for a zombie. Creating it dirties it, before any modifier exists.
Then the effect removes its own id, which dirties nothing when the id is not
there, and adds the +6 with AttributeInstance.addPermanentModifier — the
remove is not optional, since the plain add throws on a duplicate id — which
dirties it. Each time the
callback adds to the update set and consults the syncable flag before touching
the sync set, so the sync set stays empty and no attribute packet is sent at
all. Next tick, ServerEntity.sendDirtyEntityData finds no attribute to send
(it does send the swirl the effect put in the entity data), and
LivingEntity.onAttributeUpdated matches none of its four branches.
Three seconds later Player.attack asks LivingEntity.getAttributeValue for
the attack damage. The instance is dirty, so AttributeInstance.calculateValue
runs: the addition bucket holds the sword’s base_attack_damage modifier
from DataComponents.ATTRIBUTE_MODIFIERS and the effect’s +6, the two
multiplication buckets are empty, the clamp passes, the result is cached. The
rest of the swing — cooldown scale, enchantment bonuses, the crit — belongs
to the sword swing. Expiry runs it backwards:
LivingEntity.onEffectsRemoved calls MobEffect.removeAttributeModifiers,
which removes by id from all three indices and dirties one last time.
Swap Strength for Speed and the missing limb appears
MobEffects.SPEED targets Attributes.MOVEMENT_SPEED with
AttributeModifier.Operation.ADD_MULTIPLIED_TOTAL, and movement speed is
syncable. Everything above is identical until the callback, which now fills
the sync set too. ServerEntity.sendDirtyEntityData drains it in the next
chunkSource phase and emits a ClientboundUpdateAttributesPacket carrying
the base value and every modifier on that attribute — sprinting, powder
snow, the effect, all of them — and ClientPacketListener.handleUpdateAttributes
rebuilds the client’s instance from scratch. That difference, one boolean on
the Attribute, is the whole design.
Where to look
Attributes is forty field initialisers and the syncable flag is visible in
each of them, so start there; DefaultAttributes and
AttributeSupplier.Builder.build are the prototypes and the freeze. Then read
the three methods this page turns on, in this order:
AttributeMap.getInstance, because asking is a mutation;
AttributeMap.onAttributeModified, the four lines that decide which set a
change lands in; and AttributeInstance.calculateValue, the three passes and the clamp. LivingEntity.refreshDirtyAttributes and
ServerEntity.sendDirtyEntityData are where the two sets drain, in different
phases. Two doors: ItemAttributeModifiers.compute, the second and disagreeing
implementation of the arithmetic, and AttributeCommand, whose modifier add
is the plainest way a player can make a permanent modifier by hand.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Movement and collision
Verified against Minecraft 26.3 · Part VI · One tick of a falling zombie: 0.08 of gravity, one swept box against a stone floor, and the four booleans everything downstream reads.
A zombie has been falling for a few ticks and this is the one where it reaches
the stone. Its
tick builds one delta vector, hands it to Entity.move, gets back the part
of it the world allowed, and sets four booleans from the difference —
Entity.horizontalCollision, Entity.verticalCollision,
Entity.verticalCollisionBelow and Entity.minorHorizontalCollision. Then it
has to answer a harder question: what did I just walk through? It does not
answer that by sampling the destination. Every segment of the tick’s
movement was recorded into a deque, Entity.movementThisTick, and
Entity.applyEffectsFromBlocks replays those segments afterwards — in the
same axis order the collision used, visiting every block the swept box
actually crossed, AABB.collidedAlongVector rather than a static overlap.
And the ignition, freezing and extinguishing that replay finds are not applied
where they are found: they are queued into an
InsideBlockEffectApplier.StepBasedCollector and flushed
in InsideBlockEffectType declaration order, so fire and water touched in
the same step of the replay always end in the extinguish, whatever order
the blocks came in.
The cast
| class | what it decides | thread |
|---|---|---|
Entity | the geometry: clipping, stepping up, bouncing, whether you are on the ground and what you are standing on | the Server thread, the Render thread for whatever is authoritative there, and both for an item, an orb, a falling block or lit TNT, or anything a piston or a shulker pushes |
LivingEntity | the physics above it: gravity, drag, friction, swimming, gliding, climbing | same |
CollisionGetter | which blocks are candidates, through BlockCollisions, and which one is holding you up | same |
Shapes / VoxelShape | the clipping arithmetic, one axis at a time | same |
Entity.Movement | one recorded segment — from, to, and, for a segment a move recorded, the pre-collision vector that fixes the replay’s axis order | same |
InsideBlockEffectApplier.StepBasedCollector | when an ignition, a freeze or an extinguish happens, and in what order | same |
EntityFluidInteraction | the snapshot of water and lava height, whether the eyes are in and the current, that the tick reads | same |
ServerEntity | whether this tick’s new position costs a short delta or an absolute sync | the Server thread, in the broadcast phase |
Who is allowed to run this at all
The trace below is the authoritative copy’s tick, and which copy that is
inverts between a mob and a player: nothing in a client-side mob’s own tick
moves it through Entity.move (a dying ender dragon aside), while your own player is
simulated on your machine for real. The predicate that decides is Entity.isLocalInstanceAuthoritative
rather than a bare “am I the client”, and it is stated in full once, at
authority;
this page notes each gate where the trace hits it.
The tick
One tick of a falling zombie, from the entity loop to the packet that carries
the result. The mob is one object and one lane; the class on each message is
which half of it runs, because the page’s two roles — LivingEntity’s physics
and Entity’s geometry — alternate all the way down:
sequenceDiagram
participant SL as ServerLevel
participant LE as LivingEntity
participant SE as ServerEntity
SL->>LE: tick, from ServerLevel's entity loop
LE->>LE: Entity.baseTick, and the fluid snapshot
LE->>LE: LivingEntity.aiStep: coast if nothing simulates, then serverAiStep
LE->>LE: LivingEntity.travel, which picks travelInAir
LE->>LE: Entity.moveRelative, then Entity.move
Note over LE: the resolve, and the step-up loop, are the next section
LE->>LE: Entity.setPos, then the four collision booleans
LE->>LE: Entity.setOnGround<br/>WithMovement
LE->>LE: Entity.checkFallDamage
LE->>LE: the bounce, the step sound, the block speed factor
LE->>LE: back in travelInAir: 0.08 of gravity, then the drags
LE->>LE: Entity.applyEffects<br/>FromBlocks
LE->>LE: LivingEntity.push<br/>Entities, then doPush
rect rgba(0, 0, 0, 0.04)
Note over SL,SE: the next tick, the ServerLevel.chunkSource phase
SL->>SE: sendChanges: a short Pos delta, or an absolute sync
end
The line to look for is the fourth from the bottom. Gravity is subtracted after the move, not before it, so the delta a tick moves the mob by was built by the tick before — and the shaded band at the foot is the next tick again, which is when the wire hears about any of it.
Building the delta
Entity.baseTick clears the cached block state, records whether the eyes
were in water, and calls Entity.updateFluidInteraction — one sweep that
fills the water and lava trackers of EntityFluidInteraction with their
heights and accumulated current. Everything downstream — Entity.isInWater,
Entity.isInLava, Entity.getFluidHeight, Entity.isEyeInFluid — reads
that snapshot and never the live world. Fire ticks after it, lava halves
Entity.fallDistance rather than clearing it, and Entity.checkBelowWorld
discards anything 64 below the world floor — except a LivingEntity, which
overrides the hook and takes four points of fell out of the world damage a
tick instead.
LivingEntity.aiStep is the order of every mob’s tick and worth memorising:
the coast when nothing interpolates or simulates, head turn, equipment, a
deadzone that zeroes any delta component under 0.003 (a squared-horizontal test instead, for players),
LivingEntity.applyInput, Mob.serverAiStep — the goal selector and the
movement control, which set LivingEntity.xxa and LivingEntity.zza
(AI, pathfinding) — the jump
branch, gliding, the travel branch, Entity.applyEffectsFromBlocks,
animation, freezing, LivingEntity.pushEntities. Our zombie’s jump branch
is skipped before Entity.onGround is ever consulted, because the branch is
gated on LivingEntity.jumping and a falling zombie is not asking to jump.
The travel branch is a fork, not a call. If the controlling passenger is a
Player and the mob is alive it is LivingEntity.travelRidden — the path
every horse, pig and happy ghast takes, and the reason a ridden mob’s input
comes from LivingEntity.getRiddenInput rather than its own AI. Otherwise,
and only if both Entity.canSimulateMovement and Entity.isEffectiveAi
hold, it is LivingEntity.travel, which picks one of three:
LivingEntity.travelInFluid (splitting again into
LivingEntity.travelInWater and LivingEntity.travelInLava),
LivingEntity.travelFallFlying — the elytra model, lift from the square of
the pitch cosine — or LivingEntity.travelInAir.
LivingEntity.shouldTravelInFluid picks the first, and note what it reads:
the cached in-water and in-lava flags, with the live FluidState at the
block position used only for LivingEntity.canStandOnFluid, which is how a
strider walks on lava.
The knobs on the entity and the knobs on the block
LivingEntity.travelInAir probes the block below through
Entity.getBlockPosBelowThatAffectsMyMovement — 0.500001 down — for its
friction: airborne, 1.0, and on stone Block.getFriction’s 0.6 through
LivingEntity.computeModifiedFriction. For an airborne entity
LivingEntity.getFrictionInfluencedSpeed then returns
LivingEntity.getFlyingSpeed: 0.02 for a mob nobody is riding, which is
why you have almost no air control — a literal, not
Attributes.FLYING_SPEED, which is not even in the base living attribute
set. Entity.moveRelative rotates the input by the yaw and adds it to
Entity.deltaMovement through Entity.setDeltaMovement, which silently
discards the whole vector if it is not finite, so NaN never enters the
physics state.
Every knob on the entity’s side is a syncable attribute
(attributes): Attributes.GRAVITY (0.08),
Attributes.STEP_HEIGHT (0.6), Attributes.MOVEMENT_SPEED (0.7),
Attributes.JUMP_STRENGTH (0.42), Attributes.SAFE_FALL_DISTANCE (3.0),
Attributes.FALL_DAMAGE_MULTIPLIER, Attributes.MOVEMENT_EFFICIENCY,
Attributes.WATER_MOVEMENT_EFFICIENCY, Attributes.AIR_DRAG_MODIFIER,
Attributes.FRICTION_MODIFIER, Attributes.BOUNCINESS. The world’s half is
five values each block copies out of its BlockBehaviour.Properties (blocks and states) and returns from a getter:
| property | default | who changes it |
|---|---|---|
Block.getFriction | 0.6 | 0.98 on ice, packed ice and Blocks.FROSTED_ICE, 0.989 on blue ice, 0.8 on Blocks.SLIME_BLOCK |
Block.getSpeedFactor | 1.0 | 0.4 on soul sand and honey |
Block.getJumpFactor | 1.0 | 0.5 on honey |
Block.getBounceRestitution | 0.0 | 1.0 on Blocks.SLIME_BLOCK, 0.75 on beds and Blocks.SHELF_MUSHROOM |
Block.getFallDistanceReduction | 0.0 | 0.5 on beds and Blocks.SHELF_MUSHROOM |
Who else can move you
MoverType names who is moving you, in five constants. MoverType.PISTON is
the one with collision rules of its own — Entity.limitPistonMovement collapses the
vector to a single axis, Entity.applyPistonMovementRestriction clamps it to
±0.51 per game tick, and that path alone is exempt from the multiply by
Entity.stuckSpeedMultiplier — it still clears the field
(pistons). MoverType.SHULKER_BOX
makes a Shulker teleport rather than move, and MoverType.SELF and
MoverType.PLAYER are read together by Player.maybeBackOffFromEdge, which
is what stops a crouching player walking off a ledge.
Resolving one axis at a time
Entity.move opens with two things that are easy to miss.
Entity.stuckSpeedMultiplier is applied to the delta and cleared in the
same breath, zeroing Entity.deltaMovement with it — that pair is the whole
cobweb, berry-bush and powder-snow model — and Entity.noPhysics is an escape hatch above
even it: an entity with it set skips collision entirely and has all four
booleans cleared.
flowchart TD
COLLIDE["Entity.collide"]
GATHER["gather the colliders over the swept box"]
RESOLVE["Entity.collideWithShapes"]
AXIS["Direction.axisStepOrder: Y, then the larger horizontal, then the smaller"]
TEST{"step height above zero, colliding horizontally, on the ground?"}
FLAT["return the flat result"]
HEIGHTS["Entity.collectCandidateStepUpHeights, ascending, less the one already tried"]
NEXT{"a candidate height left?"}
RETRY["retry the whole resolve at that height"]
MORE{"more horizontal distance than the flat attempt?"}
WIN["return that one, with the tick's fall added back"]
COLLIDE --> GATHER --> RESOLVE --> AXIS --> TEST
TEST -- "no" --> FLAT
TEST -- "yes" --> HEIGHTS --> NEXT
NEXT -- "no" --> FLAT
NEXT -- "yes" --> RETRY --> MORE
MORE -- "no" --> NEXT
MORE -- "yes" --> WIN
Three diamonds: the first decides whether a step-up is attempted at all, the second walks the candidate heights in ascending order, and the third is asked once per height. The loop leaves with the flat result when the heights run out, and with the stepped one at the first height that gains ground.
Two things in the gathering stage surprise people. The first is that
collision is against shapes, not blocks: a candidate contributes
whatever BlockBehaviour.BlockStateBase.getCollisionShape says, which for a
fence is 1.5 blocks tall — to walk into and to stand on. The 1.0 you see
outlined is BlockBehaviour.BlockStateBase.getShape, the selection box, and
CrossCollisionBlock builds the two from different heights. The mover only
ever asks for the first. The second is that most entities are not colliders
at all: EntityGetter.getEntityCollisions wraps with Shapes.create the
box of every entity that answers Entity.canBeCollidedWith, and the base
class answers false — so the mob standing next to you contributes
nothing, while boats, living shulkers and happy ghasts do. (Pushing is a different
predicate, Entity.isPushable, and belongs to the crowding pass below.)
BlockCollisions walks the box with a Cursor3D, reads chunks through
CollisionGetter.getChunkForCollisions — a full chunk if it is already
there, null otherwise, and a missing chunk is simply stepped past, so an
entity at the edge of loaded space falls through empty space rather than
blocking the tick. A full cube short-circuits to a box intersection;
anything else goes through Shapes.joinIsNotEmpty.
Entity.collideBoundingBox is the flat attempt itself — the public static that
gathers the colliders and hands them to Entity.collideWithShapes, with no
step-up in it at all — and Entity.collide calls it once before deciding
whether stepping up is worth trying. The step-up loop in the figure is the part
worth slowing down for. It does
not guess a height and it does not pick the best one. It harvests the Y
coordinates of the candidate shapes that lie above the entity’s feet and
within Entity.maxUpStep, skipping the height the flat attempt already
tried, sorts them
ascending, and retries the whole resolve at each until one yields any more
horizontal distance than the flat attempt — and returns that one with this
tick’s fall added back in, because the box it stepped from was the one
already lowered by this tick’s vertical movement. It is the
lowest step that helps, which is also why an entity can step onto a shape’s
internal ledge and not only its top face. Entity.maxUpStep is zero on the
base class and LivingEntity.maxUpStep reads Attributes.STEP_HEIGHT,
raised to at least 1.0 when a Player is riding — which is how a ridden
horse climbs a full block.
What the move reports back
Before committing, one clip: if Entity.fallDistance is non-zero and the
allowed movement is at least a block long, Entity.move casts a ray up to
eight blocks along it for BlockTags.FALL_DAMAGE_RESETTING or water and resets
the fall distance on any hit. Then an Entity.Movement record — from, to, and
the pre-collision delta — goes onto Entity.movementThisTick, and
Entity.setPos moves the point and the bounding box together.
The four booleans are then computed by comparing what was asked with what
was allowed. Entity.horizontalCollision is true when either horizontal
component was clipped; Entity.verticalCollision when the Y component was,
and Entity.verticalCollisionBelow when that clip was downward. The fourth,
Entity.minorHorizontalCollision, is the odd one: it asks
Entity.isHorizontalCollisionMinor, which is false on the base class and
overridden by exactly one class in the game, LocalPlayer, which is also its
only reader — a scrape too small to be a wall is the difference between
stopping a sprint and not. The comparison is Mth.equal on the two horizontals
but exact inequality on Y, and the whole vertical block only runs if the
entity moved vertically at all or is authoritative.
Entity.onGround is not a fifth boolean of the same kind: it is set from
Entity.verticalCollisionBelow, so it too is a comparison and not
a raycast. The probe for what holds you up
is CollisionGetter.findSupportingBlock, reached through
Entity.setOnGroundWithMovement and Entity.checkSupportingBlock, and it
answers which block is holding you (for sounds and the speed factor), not
whether — probing a paper-thin slab under the box, retrying with the box
shifted back along the movement if that finds nothing, and setting
Entity.onGroundNoBlocks when it still does.
Landing, and the fall distance that resets from many places
Entity.checkFallDamage runs next, only when this instance is authoritative.
It adds the downward movement to Entity.fallDistance and, on landing, calls
Block.fallOn, posts GameEvent.HIT_GROUND and resets the distance.
Block.fallOn is what calls LivingEntity.causeFallDamage, with the distance
scaled down by the block’s Block.getFallDistanceReduction;
LivingEntity.calculateFallPower subtracts
Attributes.SAFE_FALL_DISTANCE and LivingEntity.calculateFallDamage
multiplies by Attributes.FALL_DAMAGE_MULTIPLIER and checks
EntityTypeTags.FALL_DAMAGE_IMMUNE (damage). Our
two-block fall is 2 − 3 < 0, so the power is zero and nothing at all
happens: the landing particles are gated on a positive power and the fall
sound on positive damage, so only the game event and the reset fire. That
reset is reached from more places than you would guess — landing, entering
water in Entity.updateFluidInteraction, climbing in
LivingEntity.handleOnClimbable, every LivingEntity.rideTick, under
MobEffects.SLOW_FALLING or MobEffects.LEVITATION at the top of the
travel branch, Entity.makeStuckInBlock, a bubble column, a honey block’s
slide, a mace’s smash, and the tag-or-water clip above. Lava halves it instead.
Then Entity.restituteMovementAfterCollisions, gated on
Entity.canSimulateMovement: a real restitution model, not slime-block
code. It reflects the horizontal components, and for a downward hit combines
Attributes.BOUNCINESS with Block.getBounceRestitution, damped to 80% for
non-living entities, gated on the impact being at least one tick of gravity
— which is why nothing jitters at rest on a slime block — and opted out of
by BlockTags.SUPPRESSES_BOUNCE or by crouching. A bounce posts
GameEvent.BOUNCE (game events)
and sets Entity.syncPosition. Entity.applyMovementEmissionAndPlaySound
follows, gated on not client-side or authoritative: it accumulates
Entity.moveDist and fires Entity.playStepSound plus GameEvent.STEP
when it passes Entity.nextStep. Last, the horizontal components are
multiplied by Entity.getBlockSpeedFactor — soul sand’s 0.4, lerped towards
1 by Attributes.MOVEMENT_EFFICIENCY.
And then gravity
Control returns to LivingEntity.travelInAir, after the move, and only
now is gravity subtracted: Entity.getEffectiveGravity, 0.08, or capped at
0.01 while falling with MobEffects.SLOW_FALLING. MobEffects.LEVITATION
replaces that step entirely rather than modifying it, and a client-side
entity standing over an unloaded chunk gets a hard-coded −0.1. The
horizontals are then multiplied by block friction times a 0.91 scaled by
Attributes.AIR_DRAG_MODIFIER, the vertical by a 0.98 scaled by the same —
Attributes.FRICTION_MODIFIER touches only the block-friction term, and
block friction is 1.0 unless Entity.onGround. The whole drag step is
skipped when LivingEntity.shouldDiscardFriction is set. Climbing straddles
the move: LivingEntity.handleOnClimbable clamps the fall speed on a
climbable block before it, and a separate clamp in
LivingEntity.handleRelativeFrictionAndCalculateMovement after it sets the vertical
component to 0.2 when a climbing or powder-snow entity is either colliding
horizontally or jumping — which is the whole of “you go up a ladder by
pressing into it”.
So the delta Entity.move consumes carries the previous tick’s gravity.
That is one of two conventions in the codebase, and the other is
Entity.applyGravity, which most of its callers run before the move. An ItemEntity does
it the other way, and the contrast is the clearest way to see both.
ItemEntity.tick applies gravity (a default of 0.04) before Entity.move
and drag after it, reverses any downward velocity on landing at half
strength — items bounce, they do not merely damp — and skips the move
entirely when it is resting still on the ground and the tick count says it is
not this item’s turn, calling
Entity.applyEffectsFromBlocksForLastMovements on the previous tick’s
segments instead. Its Entity.getMovementEmission is
Entity.MovementEmission.NONE, so it makes no step sounds. Neither
convention is wrong.
The fluid snapshot is not taken only once a tick, and usefully so:
LivingEntity.checkFallDamage re-runs Entity.updateFluidInteraction from
inside Entity.move whenever the entity is not already in water (and
ItemEntity.tick, PrimedTnt.tick and AbstractMinecart.tick re-run it too), which is exactly why falling into water
cancels the fall damage in the same tick that entered it.
What did I pass through
Entity.applyEffectsFromBlocks runs on the same gate as the step sound —
not client-side, or authoritative. It drains Entity.movementThisTick into
Entity.finalMovementsThisTick first — substituting a single old-position-to-
position segment when the deque is empty, and appending a final segment when
the entity ended somewhere the last recorded one did not — and only then runs
the replay, which opens by calling Block.stepOn for the block underfoot,
gated on Entity.onGround.
Each recorded segment is replayed in the same axis order the collision used —
Direction.axisStepOrder again, over the segment’s stored pre-collision
vector, while a substituted, appended or merged one is swept straight — and Entity.checkInsideBlocks walks each leg with
BlockGetter.forEachBlockIntersectedBetween, testing each block with
AABB.collidedAlongVector (through Entity.collidedWithShapeMovingFrom)
rather than a static overlap at the destination, and calling
BlockBehaviour.BlockStateBase.entityInside, Entity.onInsideBlock and
FluidState.entityInside on what it finds. Entity.visitedBlocks is the
deduplicator: a block is visited at most once across the whole replay,
however many segments cross it. Two budgets bound the work — sixteen sweep
steps per segment, which is not sixteen blocks, because every block the box
covers at one end of the sweep shares a single step index; and
Entity.movementThisTick merges its two oldest entries once it reaches a
hundred, buying bounded memory with a little precision. A segment that
exhausts its steps gets one last zero-length visit at the destination, which
covers every block the box ends up inside.
Ignition, freezing and extinguishing are not applied inline, though most of
what a block does is (a cobweb’s slowdown, a cactus’s or a campfire’s damage).
Each of those is queued into the
InsideBlockEffectApplier.StepBasedCollector, which flushes a step’s worth
at a time in InsideBlockEffectType declaration order —
InsideBlockEffectType.FREEZE, InsideBlockEffectType.CLEAR_FREEZE,
InsideBlockEffectType.FIRE_IGNITE, InsideBlockEffectType.LAVA_IGNITE,
InsideBlockEffectType.EXTINGUISH — so fire and water touched in the same
step always end in the extinguish. The reordering is strictly per step:
InsideBlockEffectApplier.StepBasedCollector.advanceStep flushes as the
replay advances and
InsideBlockEffectApplier.StepBasedCollector.applyAndClear runs the
accumulated list at the end, so across steps the order stays chronological
and fire in a later step than water still burns you.
The crowding pass closes the tick
LivingEntity.pushEntities closes the tick. It collects pushable
neighbours through Level.getPushableEntities — a different predicate from
the collision one, and on the client ClientLevel.getPushableEntities
returns at most the local player, never the crowd. On a server it applies
GameRules.MAX_ENTITY_CRAMMING (default 24, checked on a one-in-four roll each
tick, the damage 6) and then calls LivingEntity.doPush → Entity.push, a
horizontal-only impulse scaled by 0.05 and ignored below a hundredth of a
block.
And the tick after: what this one costs on the wire
Nothing has crossed the network yet. ServerEntity.sendChanges runs in the
chunk-source phase of ServerLevel.tick, which comes before the entity
loop (the level tick) — so this tick’s
movement is broadcast at the start of the next one. It becomes a short delta,
ClientboundMoveEntityPacket.Pos, only when it can: not too big for a
short, no more than 400 gated evaluations since the last absolute sync —
ServerEntity.teleportDelay is incremented inside the gate, so for an entity
tracked every tick on the default interval that is about 1,200 ticks, fewer
when its data or a push opens the gate, and more when the tracker is not
called at all — not riding, the
entity does not demand precision, and Entity.onGround still matches what
the last absolute sync recorded. The last condition is the one that fails
most often, and it is a real cost: a landing or a step off a ledge that a gated call
sees forces a full ClientboundEntityPositionSyncPacket. The bounce’s
Entity.syncPosition flag re-phases the tracker’s own counter to the next
interval boundary for an entity with no stepped interpolation, so its send
happens at the next evaluation rather than up to an interval late; for a
living entity like this zombie (every one but a shulker), its SteppedInterpolationTracker takes the
flag first and records the bounce as a step instead.
ClientboundSetEntityMotionPacket carries the delta
separately (what the client is
told
owns the choice between the two shapes). On the receiving side,
ClientPacketListener.handleEntityPositionSync moves only an entity that is
not locally authoritative
(authority), and
snaps rather than interpolates past 64
blocks of correction — otherwise it feeds the entity’s InterpolationHandler,
for a living entity other than a shulker a stepped one whose step count is its update interval,
three by default, which Entity.commonTick steps just before the entity’s
tick; the coast branch LivingEntity.aiStep opens with is what runs when
nothing is interpolating.
Where to look
Two methods hold almost all of this and are worth reading end to end in this
order: LivingEntity.aiStep, which is every mob’s tick in one list, and
Entity.move, which is the geometry. Under the second, Entity.collide and
Entity.collideWithShapes are the axis-at-a-time resolve, with the step-up
loop in Entity.collide and Entity.collectCandidateStepUpHeights harvesting
its heights; BlockCollisions is what supplies them with candidates, and
CollisionGetter.findSupportingBlock is the probe for what holds you up. For the replay, read
Entity.applyEffectsFromBlocks and then Entity.checkInsideBlocks, with
InsideBlockEffectType open beside them for the order the collector flushes in.
LivingEntity.travelInAir is where gravity and drag are spent, after
the move rather than before it. Two doors: ItemEntity.tick, the other
convention run end to end in one method, and InterpolationHandler, which is
what moves an entity when nothing on that side simulates it.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
AI: goals and brains
Verified against Minecraft 26.3 · Part VI · A villager’s day — wake, claim a job site, work until the schedule turns, walk home to bed — and the same tick under a zombie that has none of it.
It is dawn in a village. One villager climbs out of bed, walks to its
composter and works there until the afternoon. Ten blocks away a zombie
catches fire, sees the villager, and comes for it. Both are Mobs, both are
driven by the same Mob.serverAiStep on the Server thread, and neither is
running a script: each is being re-asked, every tick or every other tick,
what it would like to be doing now. They are asked in two completely
different ways, and the villager’s is the surprising one. Its day looks like a
timetable held on the villager, and it is not one. A Brain holds an
EnvironmentAttribute of Activity — a pointer into the world rather than a
table on the mob — and Brain.updateActivityFromSchedule asks the
EnvironmentAttributeSystem what that attribute’s value is at this position,
at this time. The villager goes to bed because it asked the world
what hour it is where it is standing. The answer comes out of a data-pack
Timeline, Timelines.VILLAGER_SCHEDULE: a 24000-tick loop whose adult track
names the tick each activity starts — idle at 10, work at 2000, meet at 9000,
idle again at 11000, rest at 12000 — beside a baby track that swaps play in.
Because the lookup takes a position, the day can in principle differ by
location. That system is environment attributes and
timelines;
this page only asks it a question.
The cast
| class | what it decides | thread |
|---|---|---|
GoalSelector | which goals run, by holding a four-entry table of Goal.Flag to the goal that owns it | server, from Mob.serverAiStep |
Goal | whether it wants to run, whether it may be interrupted, and which flags it needs | as above |
WrappedGoal | the arbitration — WrappedGoal.canBeReplacedBy — plus the priority and the running bit | as above |
Brain | which activity is active, what the memories hold, and which behaviours may start at all | server, from Mob.customServerAiStep |
MemoryModuleType | the vocabulary a brain thinks in: 115 constants, of which the 52 with a codec are the mob’s entire saved mind | declared, never ticked |
Sensor | when to look at the world, on its own scan rate, and which memories to write | server, from Brain.tick |
ActivityData | one activity’s prioritised behaviour list, its memory requirements, and the memories erased when it stops | built per body by Brain.ActivitySupplier |
Sensing | whether this mob can see that entity, memoised for exactly one tick — and both systems go through it | server, cleared at the top of Mob.serverAiStep |
Seven things they do differently
| the goal selector | the brain | |
|---|---|---|
| what holds the state | GoalSelector.availableGoals, an insertion-ordered set of WrappedGoal, beside a lock table and a set of disabled flags | a memory map, a sensor map, and Brain.availableBehaviorsByPriority — priority to activity to behaviour set |
| what fills it | Mob.registerGoals, once, from the constructor, and only when the level is a ServerLevel | Brain.Provider.makeBrain, from an activity list built per body — and, for a villager, built again when its body changes |
| what decides | Goal.canUse, re-asked on every other tick | Behavior.hasRequiredMemories then Behavior.checkExtraStartConditions, asked once a tick |
| what arbitrates | the flag table. Lower priority number wins a contested flag, and a non-interruptable incumbent wins outright | the active activity. A behaviour whose activity is not active is not started at all |
| what persists across a save | nothing. Not the running set, not the flags | 52 of the 115 memories, through Brain.Packed |
| what the world can push in | Mob.updateControlFlags every five ticks, and the leash, both on one selector only | the schedule attribute, POI claims, hostiles seen by sensors, Attributes.FOLLOW_RANGE |
| which mobs use it | every Mob ticks one, and almost every mob without a brain fills it. 58 goal classes and 10 targeting ones | 20 classes override LivingEntity.makeBrain — but only Villager sets a schedule |
Where both of them sit in one mob tick
The profiler section names, because they are what a profile shows:
LivingEntity.tick
LivingEntity.aiStep
"ai" the guard: server side, and Mob.isEffectiveAi
"newAi" Mob.serverAiStep
"sensing" Sensing.tick — the line-of-sight memo is cleared
"targetSelector" ┐ GoalSelector.tick on the full pass,
"goalSelector" ┘ tickRunningGoals(false) on the off tick
"navigation" PathNavigation.tick — see pathfinding
"mob tick" Mob.customServerAiStep → "villagerBrain" → Brain.tick
"controls" "move" / "look" / "jump"
"jump" LivingEntity's own jump handling — outside the guard
"travel" LivingEntity.travel — where the body actually moves
"headTurn" Mob.tickHeadTurn — no side check, so both sides
Note the scope of that guard. LivingEntity.aiStep runs on the client too;
what it wraps in server side and effective AI is the one call to
Mob.serverAiStep, not the jump and travel sections beneath it.
Mob.isEffectiveAi is the more interesting half of the condition, and what
it is — one of five predicates that decide which copy of an entity may do
anything — belongs to
authority.
What belongs here is the guard’s scope. On the client neither selector nor brain is ticked at all — only the
jump, travel and head-turn sections beneath the gate, and the debug
renderers.
Each of the seven rows of that table is a section below, in the table’s own order.
What holds the state
GoalSelector holds three things and none of them is a plan: a set of
WrappedGoal, a map from Goal.Flag to the goal currently holding it, and
a set of flags that have been switched off. There is no state machine and no
sequence. The only persistent state a goal system has is which goals are
running and which flags are held, and a Mob keeps two independent
copies of it, Mob.goalSelector and Mob.targetSelector.
A Brain holds a great deal more, and the piece to keep hold of is
Brain.availableBehaviorsByPriority: a sorted map from priority, to
activity, to a set of BehaviorControl. Priority is the outer key, so the
whole brain is walked in priority order regardless of which activity a
behaviour belongs to. Beside it sit the memory map, the sensor map, the
per-activity requirements, the per-activity erase lists, the core
activities, the active set and a default of Activity.IDLE.
Two things about ownership surprise people. Brain is declared on
LivingEntity, not on Mob — every living entity has one, the player
included, and the base implementation hands back an empty one that reports
itself Brain.isBrainDead. And the activity list is not static: a
Brain.Provider is what builds a brain, and it asks its
Brain.ActivitySupplier for that list per body, which is how a
villager’s profession selects its work package.
What fills it
Goals go in once, for almost every mob. The Mob constructor calls Mob.registerGoals
only when the level it is being built into is a ServerLevel, so a
client-side mob’s selectors stay all but empty for its whole life (a few mobs
add a goal from the constructor or from loaded data, which nothing on the
client ever ticks). After that the set
is fixed, but for the few mobs that add or remove a goal on a state change.
Memories are filled continuously, by sensors and by behaviours alike. A
Sensor looks at the world and writes what it saw: the default scan rate is
20 ticks (Sensor.DEFAULT_SCAN_RATE), GolemSensor uses 200 and
SecondaryPoiSensor 40, and Sensor.randomlyDelayStart offsets each one at
construction so a village does not scan in lockstep. What registers a
memory slot is declaring it — every memory a sensor lists in
Sensor.requires and every memory a behaviour names in its entry condition
is registered when the brain is built. Reading one that was never registered
throws Unregistered memory fetched: a behaviour has been installed on a mob
that has no idea what it is talking about.
The two systems share one piece of machinery here, and it is easy to file
under the wrong heading. Sensing is the per-mob line-of-sight memo, cleared
once per tick at the top of Mob.serverAiStep, and it is not the goal
system’s alone: TargetingConditions.test routes every line-of-sight check
through Mob.getSensing, and the shared conditions the brain’s sensors use
have that check on by default.
Those shared conditions deserve a second look. Sensor holds six static
TargetingConditions objects, used by every brain mob in the world, and
re-ranges all six from this body’s Attributes.FOLLOW_RANGE immediately
before every scan. That is correct only because AI is strictly
single-threaded — there is not a future, an executor or a thread anywhere in
the AI packages — and it is about as clear a demonstration of the fact as the
codebase offers.
What decides
A goal is asked Goal.canUse every other tick, staggered across mobs by
tickCount + id, with an exception for a mob’s first tick, where the
full pass runs whatever the parity. On the off tick
GoalSelector.tickRunningGoals is called with false, so only goals that
answer Goal.requiresUpdateEveryTick are ticked at all — and, more
importantly, no goal is stopped, because the Goal.canContinueToUse
sweep lives in the full pass. A goal that lost its reason to run on an off
tick keeps running until the next even one.
The full pass, GoalSelector.tick, is three phases, and they are the next
level down in a profile. goalCleanup stops every running goal that either
holds a now-disabled flag or fails Goal.canContinueToUse, then drops every
lock whose holder is no longer running. goalUpdate walks the set again and
starts anything that is not running, holds no disabled flag, can take all its
flags, and answers Goal.canUse. goalTick — reached through
GoalSelector.tickRunningGoals with true — ticks the survivors.
A behaviour is asked once per tick, and the first question is not about the
behaviour at all. Brain.tick runs four fixed phases in order:
Brain.forgetOutdatedMemories, where every MemorySlot counts down and an
expired one clears itself; Brain.tickSensors, where each Sensor counts its
own scan rate down before it looks at anything; and then the two that walk the
behaviours, Brain.startEachNonRunningBehavior over
Brain.availableBehaviorsByPriority lowest number first, and
Brain.tickEachRunningBehavior over everything now RUNNING. Both of those
are complete sweeps, so what happens to one behaviour is two questions asked
in two different phases of the same tick:
flowchart TD
A["one behaviour, in one Brain.tick"]
E{"is its activity active?"}
F["never started"]
G["Behavior.tryStart — its memories, then its extra conditions"]
X["stays STOPPED until a later tick"]
H["RUNNING, to a rolled end timestamp"]
S["phase three ends, phase four begins"]
J{"timed out, or Behavior.canStillUse false?"}
K["Behavior.doStop, in the Brain.tick that started it"]
L["Behavior.tick"]
A -- "STOPPED" --> E
A -- "already RUNNING" --> S
E -- "no" --> F
E -- "yes, and it is STOPPED" --> G
G -- "either test fails" --> X
G -- "both pass" --> H
H --> S
S --> J
J -- "yes" --> K
J -- "no" --> L
One behaviour through one Brain.tick. The two diamonds are the whole of
this page: the first is asked in phase three and the second in phase four, and
a one-shot, or a behaviour that keeps the default Behavior.canStillUse, ends
at the second in the very tick it started.
The branch marked never started is what this page turns on. An activity
is a filter, not a mode, and it filters starting: a behaviour already
running when its activity leaves is still ticked, which is why SleepInBed
checks the rest activity itself. The brain’s active set is always the core
activities plus exactly one other, so Activity.CORE behaviours run at every
hour of the day and switching activity only swaps the second half. (Core
activities is plural in the API and singular in practice: nothing in 26.3
calls Brain.setCoreActivities with anything but Activity.CORE alone.)
The canStillUse branch is sharper than “a behaviour runs for one tick”.
Behavior.canStillUse defaults to false, and phases 3 and 4 are both inside
the same Brain.tick — so for a Behavior subclass that does not override
it, Behavior.tick is not called once. Everything it does, it does in
Behavior.start (a one-shot does its work in its trigger). The duration rolled at start between the behaviour’s
minimum and maximum (Behavior.DEFAULT_DURATION is 60) matters only for the
ones that do override it, which is why the same behaviour class configured
with different bounds behaves differently in two packages.
What arbitrates
On the goal side, the flag table is the arbiter, not the priority list.
GoalSelector.availableGoals is insertion-ordered and never sorted; priority
only settles a contested flag. Two goals with no flag in common run together
whatever their numbers, and two that share one never do.
WrappedGoal.canBeReplacedBy is the whole rule: the incumbent must answer
Goal.isInterruptable, and the challenger’s number must be strictly lower.
There is a small piece of craft in how that is arranged. GoalSelector never
puts a placeholder in its lock table; it reads the table with a default — a
sentinel WrappedGoal of maximum priority that reports itself not running —
so this flag is free and this flag is held by someone worse than me are
the same WrappedGoal.canBeReplacedBy call on an entry that may not exist.
On the brain side the arbiter is the active set, and the fallback is silent.
Brain.setActiveActivityIfPossible checks the target activity’s memory
requirements and, if they do not hold, calls Brain.useDefaultActivity
instead. A jobless villager at tick 2000 is not “off schedule”: the switch to
Activity.WORK fails its MemoryModuleType.JOB_SITE requirement and the
villager is idle by construction. Switching is also not free — the brain
first erases, for every activity leaving the set, the memories that
activity’s ActivityData names as memoriesToEraseWhenStopped. It is one of
the few places the brain mutates state rather than reading it.
What persists across a save
A goal system saves nothing. Which goals were running, which flags were held,
how far through an attack a mob was — all of it is rebuilt from scratch when
the chunk reloads and the constructor calls Mob.registerGoals again.
A brain saves Brain.Packed, and Brain.pack walks the memories keeping
only those whose MemoryModuleType can serialise: 52 of 115, the remaining
63 transient by construction. Time-to-live travels with them, so a memory can
expire across a reload as easily as within a tick — MemorySlot counts down
in phase 1 of every Brain.tick and clears itself at zero.
The reason Brain.Packed exists as a first-class shape is that a brain is
built more than once in a mob’s life. Villager.refreshBrain stops every
running behaviour, packs the current memories, and runs the provider again
from the packed state with a fresh activity list. Changing profession does
that; so does growing up, which is how a baby swaps
EnvironmentAttributes.BABY_VILLAGER_ACTIVITY for
EnvironmentAttributes.VILLAGER_ACTIVITY.
What the world can push in
Into a goal selector, two things, and both of them work by switching a flag
off rather than by asking for anything. The first is Mob.updateControlFlags,
called from Mob.tick on the server every five ticks. It sets
Goal.Flag.MOVE and Goal.Flag.LOOK from one question — is a Mob
steering me — and Goal.Flag.JUMP from that and am I in an
AbstractBoat. So a mob a mob is riding loses all three, and a mob sitting
in a boat by itself loses only the jump.
The second is the lead in your hand. Leashable.tickLeash runs from
Entity.baseTick (entity
anatomy
puts it among the capability interfaces), and when the mob is pulled past the
distance a lead can hold, Mob.leashTooFarBehaviour breaks the lead and
disables Goal.Flag.MOVE outright — so a mob that has just snapped its lead
is not choosing to stand still, it has had the flag its movement goals need
taken away — until Mob.updateControlFlags puts it back within five ticks.
Both levers touch Mob.goalSelector only; Mob.targetSelector is never
disabled.
GoalSelector.tick then stops any running goal holding a disabled flag and
refuses to start another.
Into a brain, rather more: the schedule attribute, whose value comes from the
world; hostiles, players, items, beds and golems, all written by sensors that
query the level; and POI claims, which go through the shared PoiManager
(points of interest). And one attribute change reaches into the machinery
itself: a new Attributes.FOLLOW_RANGE re-ranges the shared targeting
conditions above and, through Mob.onAttributeUpdated, resizes how far the
pathfinder may search
(pathfinding).
Neither of them crosses the network
There is no AI packet. What a client
sees are consequences — head rotations, motion, position deltas, pose
changes, the occasional entity-event byte — plus a debug channel that costs
nothing until someone subscribes: Mob.registerDebugValues registers
DebugSubscriptions.ENTITY_PATHS and DebugSubscriptions.GOAL_SELECTORS for
every mob, and DebugSubscriptions.BRAINS only for one that is not
brain-dead.
And neither library is anything but more of the same
There
are 103 classes at the top of world/entity/ai/behavior and 61 at the top of
world/entity/ai/goal, and nearly all of them are one of the shapes above — a
behaviour with its required memories and its start conditions, whether a
Behavior subclass or a one-shot built with BehaviorBuilder, or a Goal
with its flags and its Goal.canUse — beside a handful of the machinery that
runs them and the value types they use. Reading a third is reading the first twice. The sensors are the
same — 23 Sensor subclasses, each with a scan rate and a list of memories it
writes, from TemptingSensor to PiglinSpecificSensor — and so are the
eighteen *Ai classes, which hold the lists and, for the busier mobs, the
helpers the lists call.
Nor is either of them data-driven. The villager’s day is data
(Timelines.VILLAGER_SCHEDULE in Registries.TIMELINE) — but
VillagerProfession is not — a BuiltInRegistries entry bootstrapped from
code, with no directory under the built-in data pack, and neither is the
PoiType it names (points of
interest). And
behaviours and goals are code too,
plain Java lists in VillagerGoalPackages and the *Ai classes, not
registered and not addressable from a data pack.
Which mobs use which
Every Mob has both fields, and almost every mob uses exactly one. Twenty
classes override LivingEntity.makeBrain — Villager, Piglin, Warden,
Hoglin, Frog, Allay, Axolotl, Goat and twelve more — and each keeps
its behaviour lists in a class named for the mob: PiglinAi, WardenAi,
FrogAi. There are eighteen such classes for twenty mobs, and the two
exceptions are worth naming. Zoglin keeps its lists inline, in the mob
itself. And Villager’s live in VillagerGoalPackages — genuinely the 26.3
name, and not a typo: it is the last survivor of the old convention, on a
mob that has no goals at all. Nor do most of the other nineteen: a brain mob
typically registers none.
One brain mob has a schedule: Brain.setSchedule has exactly two
call sites and both are in Villager, picking the adult attribute or the
baby one. The other nineteen never consult a clock: they call
Brain.setActiveActivityToFirstValid, which walks a priority list and takes
the first activity whose memory requirements hold. That is how PiglinAi
picks fight over idle and FrogAi picks tongue over swim, with no
time of day involved anywhere.
The brain’s trace: a villager’s day
Put the two phases together over one villager and the priorities become a clock. Four behaviours, in the order phase three reaches them:
sequenceDiagram
participant Brain as Brain
participant MTS as MoveToTargetSink
participant AP as AcquirePoi
participant UAFS as UpdateActivity<br/>FromSchedule
participant EAS as Environment<br/>AttributeSystem
participant SIB as SleepInBed
rect rgba(0, 0, 0, 0.04)
Note over Brain,SIB: one Brain.tick, phase three, ascending priority
Brain->>MTS: priority 1, core
Note over MTS: a wanted position leaves here for the pathfinder
Brain->>AP: priority 6, core, which runs only while JOB_SITE is empty
Brain->>UAFS: priority 99, last in the package
UAFS->>Brain: updateActivityFromSchedule
Brain->>EAS: getValue, VILLAGER_ACTIVITY at this position
EAS-->>Brain: WORK, from tick 2000
Brain->>Brain: setActiveActivity<br/>IfPossible
end
Note over Brain,SIB: with a job site held, the next Brain.tick is the first to run the work package
rect rgba(0, 0, 0, 0.04)
Note over Brain,SIB: after tick 12000, REST, which has no requirement
Brain->>SIB: rest package, priority 3, once the walk home reaches the bed
Brain->>SIB: tickOrStop, and again every tick until dawn
end
A villager’s day as four behaviours in priority order; the two shaded bands
are two ticks about ten thousand apart. The one to watch is the schedule
behaviour at 99: it runs last, so the activity it asks for is the next tick’s
— and Brain.setActiveActivityIfPossible takes it only if that activity’s
requirements are met, and falls back to the default without saying so.
Read the priorities in that diagram as the ordering claims they are. The
schedule behaviour is UpdateActivityFromSchedule, and it sits at 99, the
last slot in every package that has one,
so the activity a villager switches to is never the one the rest of this
tick runs: the switch lands and the next tick acts on it. And it is only
consulted when a behaviour asks — Brain.updateActivityFromSchedule refuses
unless more than twenty ticks have passed since the last one. Five of the ten packages carry no such behaviour: core,
panic and hide have nothing at 99 at all, and pre-raid and raid have
ResetRaidStatus there instead. Core is never the activity a villager is
stuck in, because it is always active alongside one other; the omission is how
the other four pin the
villager, and each carries its own way out rather than leaving it
to the clock: VillagerCalmDown sits at priority 0 in the panic package and
calls Brain.updateActivityFromSchedule itself once the fear memories
clear, SetHiddenState does the same for hide, and ResetRaidStatus for the
two raid packages. Nothing is asking the clock on a schedule; each escape
hatch asks on its own terms, and the brain’s twenty-tick throttle meters them
all.
The rest of the day hangs off that. Claiming a job site is AcquirePoi
from the core package, and the claim itself — the scan, the best five, the
one pathfind that decides which of them is reachable — belongs to points of
interest,
told there against a bed. A bell across a
ravine is invisible to a villager. What is this page’s is what the memory
then means: AcquirePoi writes
MemoryModuleType.POTENTIAL_JOB_SITE, not the job site itself;
AssignProfessionFromJobSite waits until the villager is within two blocks
of that position, then erases the memory, writes MemoryModuleType.JOB_SITE
and sets the profession if it has none — which is why walking to the
workstation is a required step and not decoration, and why a villager that
seems to be ignoring a perfectly good workstation is usually in one of three
states rather than broken: it claimed the block and has not arrived yet, so
the memory still says potential; the pathfind inside AcquirePoi failed and
the site was skipped as unreachable; or it did not arrive within 1,200 ticks
and GoToPotentialJobSite let the claim go. Three more behaviours can take a
claim away afterwards — PoiCompetitorScan hands a contested one to the more
experienced villager, YieldJobSite has a villager with no profession hand its
claim to a neighbour whose profession works there, and ValidateNearbyPoi
erases it when the block is gone.
Work is a weighted RunOne — one of the GateBehavior composites, which
are behaviours whose children are behaviours — over six: WorkAtPoi (or
WorkAtComposter),
StrollAroundPoi, StrollToPoi, StrollToPoiList, HarvestFarmland and
UseBonemeal;
WorkAtPoi wants 300 ticks since the last check and 1.73 blocks or less to
the workstation. Walking anywhere is MoveToTargetSink, entered on walk
target present, path absent: it turns a MemoryModuleType.WALK_TARGET into
a path, hands it to the navigation, and records a failure as
MemoryModuleType.CANT_REACH_WALK_TARGET_SINCE. Below that hand-off is
pathfinding and then movement and
collision. Bed is SleepInBed, whose entry
conditions belong to the night shift.
What matters here is that it is this page’s counter-example: it overrides
Behavior.canStillUse, so, like MoveToTargetSink and unlike a one-shot, it
is still running on the tick after the one that started it, and it overrides
Behavior.timedOut to answer no, so it never times out. It
ends when Brain.isActive for Activity.REST goes false at dawn.
The goal selector’s trace: the zombie
A Zombie has a Brain, because every LivingEntity does, but it is the
base one — no memories, no sensors, no behaviours, Brain.isBrainDead true.
Almost everything it will ever do comes out of Mob.registerGoals, run once
in the constructor: seven goals in Mob.goalSelector — a turtle-egg attack
goal, a SpearUseGoal, a ZombieAttackGoal, a MoveThroughVillageGoal, a
WaterAvoidingRandomStrollGoal, a LookAtPlayerGoal and a
RandomLookAroundGoal — and five in Mob.targetSelector, one
HurtByTargetGoal and four NearestAttackableTargetGoals. Almost, because
a zombie is one of the few mobs that adds a goal later:
Zombie.setCanBreakDoors, rolled against local difficulty at spawn, inserts a
thirteenth — a BreakDoorGoal at priority 1, above everything the
constructor registered — and takes it out again when the flag is cleared.
Every other tick each of the twelve is re-asked, and the flag table settles
it. The target goals hold Goal.Flag.TARGET and write Mob.setTarget. The
attack goals hold Goal.Flag.MOVE and Goal.Flag.LOOK and drive the
navigation, with SpearUseGoal at priority 2 sitting above ZombieAttackGoal
at 3 — though it wants a spear in the main hand, so for most zombies the
attack goal is the one holding them. LookAtPlayerGoal wants
Goal.Flag.LOOK alone and RandomLookAroundGoal wants Goal.Flag.MOVE as
well, and both lose to whoever already has them. No activity, no schedule,
nothing persisted but a door-breaking flag, nothing the world can push in but
the control flags and a neighbour’s alert: the zombie’s entire mind is a handful of running bits and
one target field. Even that field is not read
directly — Mob.getTarget filters through Mob.asValidTarget on every call,
so a target that turned creative or spectator is gone the moment it is asked
for, and nine of the brain mobs source theirs from Mob.getTargetFromBrain
instead.
For a 1.21-era reader. There is no Schedule class in 26.3 and no schedule registry, so the file a villager’s timetable used to live in is gone. What replaced it is a
TimelineinRegistries.TIMELINE, read through anEnvironmentAttributerather than off the mob:Timelines.VILLAGER_SCHEDULEis the data,Brain.setScheduletakes an attribute rather than a schedule, and the lookup now takes a position.Activity,MemoryModuleType,Sensorand the behaviour classes keep their shapes, andworld/entity/scheduleis a package that still exists and holds one class,Activity.
Where to look
The two systems have one entry point each and they are short:
GoalSelector.tick, whose three phases are the whole arbiter, and
Brain.tick, whose four are. Read WrappedGoal.canBeReplacedBy beside the
first — it is the entire priority rule in one method — and
Behavior.tryStart and Behavior.canStillUse beside the second, with
OneShot, because between them they explain which behaviours see a second tick.
Mob.registerGoals on any mob shows a goal list; VillagerGoalPackages shows
what a brain’s lists look like instead, and Villager.refreshBrain is how one
is rebuilt mid-life. Brain.updateActivityFromSchedule is the method the
opening is about. Two doors: BehaviorBuilder, the DSL the one-shot
behaviours, over half of them, are declared with, and Sensing, the one-tick
memo both systems share.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Pathfinding
Verified against Minecraft 26.3 · Part VI · A mob walks into a fence, stands there, and then wanders off — a hundred ticks after a behaviour told it where to go.
A behaviour has produced a position and stopped caring. Everything between
that position and a mob leaning into a direction is this page: one
A* search over a snapshot of already-loaded chunks, a Path of nodes, and a
control that has to be told again every single tick. The part of it people
recognise is the failure. Giving up is machinery, not an absence of it —
every node the mob is walking towards carries a timeout computed from its
distance and the mob’s current speed, three times over that budget abandons
the path outright, and a separate check every hundred ticks asks whether the
mob has covered a quarter of the ground its speed says it should have. The
mob you watch walk into a wall and then wander off is running a scheduled
surrender.
Above the waterline is goals and brains, which decides where; this page is how, and it is the same machinery whichever decision system asked.
The cast
| class | what it decides | thread |
|---|---|---|
PathNavigation | when to search, what budget to search with, when to give up | the Server thread |
PathNavigationRegion | which blocks the search is allowed to see — a snapshot, never a load | the Server thread |
NodeEvaluator | what a block is to this mob, as a PathType | the Server thread |
PathTypeCache | the 4,096-entry memo that makes that affordable | the Server thread, owned by ServerLevel |
PathFinder | the A* itself, bounded by a node budget | the Server thread |
Path | the node list, and whether it reaches the target | the Server thread, with a stream codec for the debug channel only |
MoveControl | where a wanted position becomes a yaw and a speed, and forgets it again the same tick | the Server thread |
Nothing here is asynchronous. There is not a future, an executor or a thread
anywhere in the pathfinder, and every entry point takes a ServerLevel or a
Mob on one. Nothing crosses the network either, except when a debug client
has subscribed.
The pipeline
Two ways in, one gate, and three ways the navigation ends it by itself (a caller can stop it too). What happens between the gate and the ending is the search, which has a figure of its own:
flowchart TD
WANT["a goal or a behaviour: PathNavigation.moveTo, or PathNavigation.createPath"]
WORLD["the world: PathNavigation.recomputePath, after a block update"]
GATE{"any of the early exits?"}
NULL["null, or the existing path unchanged"]
RUN["the search, then the follow, one node at a time"]
GIVEUP{"arrived, stuck for 100 ticks, or the node timed out?"}
DONE["PathNavigation.isDone, and the caller finds out next evaluation"]
WANT --> GATE
WORLD --> GATE
GATE -- "yes" --> NULL
GATE -- "no" --> RUN
RUN --> GIVEUP
GIVEUP -- "yes" --> DONE
GIVEUP -- "no" --> RUN
The page’s ways in and the navigation’s own ways out. The second entrance is the one to notice: the world pushes a recompute in after a block changes, and it is the only arrow on this page that does not start with a mob wanting something.
Asking: the ways a search does not happen
PathNavigation.createPath refuses before it does anything expensive. It
returns null on an empty target set, on a mob below Level.getMinY, and on
PathNavigation.canUpdatePath — which for GroundPathNavigation means on
the ground, in liquid, or riding something, so an airborne mob simply cannot
ask. The fourth exit is the interesting one: if there is already a path that
is not done and the target of the navigation’s last search is among the
requested targets, the existing
path is returned unchanged. Re-asking PathNavigation.createPath for a
destination you are already walking to costs nothing and changes nothing. And
GroundPathNavigation, the navigation most mobs have, puts a fifth in front of
the four for a request of one position or one entity: a target whose chunk is
not loaded returns null at once.
Most callers never name it: PathNavigation.moveTo is what a goal or a
behaviour reaches for, and its position and entity overloads call
PathNavigation.createPath themselves and hand the result to the overload that
takes a Path. Find a route and start walking it are one call from
outside, with the early exits still in front of them, and that last overload
restarts the stuck check even when the path is the one already being walked.
PathNavigation.recomputePath is the other entrance, and it is rate-limited
rather than refused. More often than every twenty game ticks, or with
PathNavigation.canUpdatePath false, it sets a flag instead and the next
PathNavigation.tick tries again. That deferral is what keeps a mob standing
in a doorway from re-searching twenty times a second.
The budget, which is also the map
One number governs both how hard the search may work and how much world it
may look at, and it is worth naming before anything is built out of it. Call it
the maximum path length: the larger of the mob’s follow range and its
required path length, in blocks. PathNavigation.setRequiredPathLength sets
the second half — 16 by default, raised in seven
classes, all of them in the mob’s constructor or its navigation: 48 for
Villager, Allay, Bee, CopperGolem and a baby HappyGhast’s
navigation, 40 for Llama, 32 for Fox.
The node budget is that length times sixteen. PathNavigation builds its
PathFinder with Attributes.FOLLOW_RANGE’s base value times sixteen, and
PathNavigation.updatePathfinderMaxVisitedNodes later recomputes it against
the modified follow range
(attributes
owns the difference between the two). The recompute has two triggers:
PathNavigation.setRequiredPathLength itself, and Mob.onAttributeUpdated,
which calls it when either Attributes.FOLLOW_RANGE or Attributes.TEMPT_RANGE
changes — though only the follow range goes into the number, and nothing the
game itself does changes it after the spawn-time bonus. PathNavigation.setMaxVisitedNodesMultiplier
scales the result, and keeps scaling it until
PathNavigation.resetMaxVisitedNodesMultiplier puts it back: Bee is the
only class that touches either.
And the same length is the map
The same maximum path length — the blocks figure, not the node budget —
becomes the radius of the PathNavigationRegion, plus an offset of 8 or 16
depending on which PathNavigation.createPath overload was used, and inside
the search it appears twice more: as a test on the current node’s distance from
the start and on each neighbour’s walked distance. A villager can find a bed 48
blocks away because its constructor set its
required path length to 48, and the larger of the two numbers wins; it
cannot find one 60 blocks away no matter how open the ground is. That is the
budget behind a villager claiming a
bed, whose
scan radius is the same 48 for exactly this reason.
That region is built by asking ChunkSource.getChunkNow for every chunk in
the cube. A path search never loads a chunk and never blocks — an absent
chunk is a null entry that reads as air. This is the same discipline
Entity.move uses for collisions, and it is why a search cannot stall a tick
waiting for a chunk.
What a block is
NodeEvaluator answers what is this position, to this mob with a
PathType: 27 constants, each carrying a default cost, and a negative cost
means impassable, not expensive. Nine of the twenty-seven are −1 —
PathType.BLOCKED, PathType.LAVA, PathType.FENCE, PathType.LEAVES,
PathType.POWDER_SNOW, PathType.DAMAGING, PathType.UNPASSABLE_RAIL and
the two closed doors; PathType.WATER is 8, PathType.FIRE 16,
PathType.OPEN and PathType.WALKABLE 0. A mob overrides any of them for
itself with Mob.setPathfindingMalus, read back through
Mob.getPathfindingMalus — which is how the same lava is free ground to a
Strider, merely expensive to a ZombifiedPiglin, and a wall to the
ordinary Piglin that never overrides it. The question a block is asked is
narrower than the answer: PathComputationType has three values — land,
water and air — and a block’s own pathability hook sees only which of the
three is being planned, never which mob is planning it.
Four evaluators in the pathfinder package cover the movement modes:
WalkNodeEvaluator, and the three that specialise it or replace it —
FlyNodeEvaluator and AmphibiousNodeEvaluator extend the walker,
SwimNodeEvaluator extends NodeEvaluator directly. Two mobs subclass one
further for themselves, Frog and Creaking. Each PathNavigation subclass chooses one:
GroundPathNavigation, FlyingPathNavigation, WaterBoundPathNavigation,
AmphibiousPathNavigation, and WallClimberNavigation on top of the ground
one.
Classifying a block is expensive enough to memo. PathTypeCache is a fixed
4,096-entry table owned by ServerLevel, consulted through
PathfindingContext — which attaches it only on a ServerLevel and
falls back to computing the type from scratch otherwise. It is invalidated
one position at a time from ServerLevel.sendBlockUpdated, which is the
subject of the last section.
The search
PathFinder.findPath is plain A* over a BinaryHeap of Nodes — one per
candidate block position, carrying its PathType and its costs — with the
destinations held separately as Targets, a Node subclass that also records
whether anything reached it. Three details of the search decide what mobs feel
like.
It is bounded twice: by a node count and by a distance. The loop breaks
as soon as its visit counter reaches the budget —
PathFinder.setMaxVisitedNodes, scaled by the multiplier — and inside the
loop the maximum path length is the second bound, gating which nodes are
expanded and which neighbours are admitted at all. Under the node budget
alone a search through open ground would reach much further than the same
budget spends in a maze; the distance bound is what stops it.
The heuristic is inflated. Each neighbour’s h is the best straight-line estimate multiplied by 1.5, which makes the search greedy: it finds a route sooner and the route it finds is not guaranteed to be the shortest. When several targets were reached, the winner is simply the path with the fewest nodes; when none was, it is the path that ends closest to a target, with fewest nodes as the tie-break.
A failed search still returns a path. If no target came within
the reach range — measured as a Manhattan distance from the popped node — the
finder reconstructs a path to the closest node it managed to reach and marks
it not reached. That is what Path.canReach reports, and it is the number
that matters rather than “was a path found”: AcquirePoi tests it before
claiming a point of interest, and MoveToTargetSink turns a false into a
cannot reach memory. A null from PathNavigation.createPath means one of
the early exits rather than a search that came back empty-handed — the
finder always has a best node to reconstruct towards.
One more thing the search does not do unless asked: it accumulates its closed
set, and attaches it to the Path, only while something is subscribed to
DebugSubscriptions.ENTITY_PATHS. PathNavigation installs that predicate
in its constructor, off the server’s ServerDebugSubscribers. Path has a
stream codec for exactly this and no gameplay reason.
sequenceDiagram
participant MTS as MoveToTargetSink
participant PN as PathNavigation
participant PNR as PathNavigationRegion
participant NE as NodeEvaluator
participant PF as PathFinder
participant MoveC as MoveControl
MTS->>PN: createPath to the walk target, then moveTo with a speed modifier
PN->>PN: the early exits, then push the pathfind profiler section
PN->>PNR: build a cube of maxPathLength plus the offset, with getChunkNow
PN->>PF: findPath — region, mob, targets, maxPathLength, reachRange, multiplier
PF->>NE: getStart, then getNeighbors per popped node
NE->>PNR: getBlockState, through PathfindingContext
Note over NE,PNR: PathTypeCache memoises the PathType per position, on a server level
PF-->>PN: a Path, reached or best-effort, with canReach set accordingly
PN->>PN: trimPath, record the stuck-check position, keep the node index
Note over PN,MoveC: every tick from here
PN->>MoveC: setWantedPosition for the next node, at speedModifier
MoveC->>MoveC: tick — the operation back to WAIT, then the yaw and the speed
The call chain is the thing to read: the navigation never talks to the evaluator, the finder does. Below the band, one search has become a position re-issued every tick for as long as the path lasts.
Following it, one tick at a time
PathNavigation.tick advances the node index and calls
MoveControl.setWantedPosition with the next node and the speed modifier.
It has to do that every tick, because MoveControl.tick sets its own
MoveControl.Operation back to MoveControl.Operation.WAIT as it handles
the move — the control is a one-shot instruction, not a destination it
remembers.
MoveControl.setWantedPosition is the main way a decision, goal or brain,
becomes movement, but it is neither the only method nor a single call site.
MoveControl.strafe is a second entrance, used by RangedBowAttackGoal and
the brain behaviour BackUpIfTooClose; MoveControl.setWait is a third.
MoveControl.setWantedPosition itself has twelve callers outside the navigations, in
eight classes: the shared goals TemptGoal.ForNonPathfinders and
TryFindLiquidGoal, the per-mob goals of Bee, Blaze, Ghast and Vex,
Rabbit from the mob itself rather than from a goal, and Fox to pin a
sleeping fox where it lies. The first of those is why a happy ghast follows a
held item in a straight line through terrain a path search would have routed
around: an adult has a navigation like any mob, and none of its goals uses it. An ordinary tempted cow runs the base TemptGoal, which calls the
navigation like anything else.
The three controls beside it
Three more controls sit beside it and are the rest of what turns a decision
into a pose — all four implementing Control, and the move and look controls
re-specialised by movement mode the way the evaluators are, so a
SmoothSwimmingMoveControl or a FlyingMoveControl is the same object with
different arithmetic. LookControl aims the head, JumpControl fires a jump
the mover then executes, and BodyRotationControl keeps the body under the
head: while the mob moves it sets the body to the mob’s own yaw, and while it
stands still a head that swings more than fifteen degrees drags the body only
far enough to stay within Mob.getMaxHeadYRot of it, and a head held still for
ten ticks has the body turn to face it over the next ten. BodyRotationControl.clientTick is a leftover
name: LivingEntity.tick calls Mob.tickHeadTurn with no side check at all,
so it runs on both sides every tick.
The mover itself is Part VI’s movement and
collision — the control sets the yaw and calls
Mob.setSpeed, which writes LivingEntity.zza with it, and
LivingEntity.travel does the rest. For a mob, LivingEntity.xxa is written
with anything but zero only by the strafe branch, which pathfinding never takes.
Giving up
PathNavigation.doStuckDetection runs two independent timers, and they answer
different questions.
The first half runs every hundred ticks:
it compares where the mob is with where it was at the last check or the last
PathNavigation.moveTo, against a
threshold of the mob’s effective speed times 100 times 0.25 — a quarter of
the ground the speed claims. Below that, PathNavigation.isStuck is set and
the path is stopped. The effective speed is the speed itself at 1.0 or above
and the square of it below, which quietly makes the threshold far more
forgiving for slow mobs.
The second half is per node. When the next node changes, the navigation
computes a time budget for it — the distance to that node divided by the
mob’s speed, times twenty — and holds it against the real ticks counted since
the last search that found a path, or the last time out, a count the change of
node does not clear, so a clear path walked long enough times out too.
Past three times that budget, PathNavigation.timeoutPath resets the
counters and stops. This one catches a mob whose path is fine and whose route
is blocked by something the search could not see, where the stuck check has
not caught it first.
Both endings are the same ending from outside: PathNavigation.isDone
becomes true, and whichever behaviour or goal was waiting on the path finds
out on its next evaluation.
The one place the world pushes back
Everything above is AI asking the world questions. ServerLevel.sendBlockUpdated
is the single call in the other direction. It invalidates the changed
position in the path-type cache unconditionally, and then — only if
Shapes.joinIsNotEmpty says the collision shape actually changed — walks
ServerLevel.navigatingMobs, every mob in the level whether it has a path or
not and unbounded by distance, asks each navigation
PathNavigation.shouldRecomputePath about the position, and calls
PathNavigation.recomputePath on the ones that say yes. That last loop —
and only that loop — is wrapped in a re-entrancy flag, which a nested block
update or a mob joining or leaving the set checks; either, arriving while the
flag is set, logs and, in a development environment, pauses.
That is why closing a door in front of a mob re-routes it and repainting a block does not.
Where to look
PathNavigation is the spine and almost every question on this page is one of
its methods: PathNavigation.createPath for the four early exits (and
GroundPathNavigation.createPath for the fifth),
PathNavigation.updatePathfinderMaxVisitedNodes for the budget,
PathNavigation.tick for the following and
PathNavigation.doStuckDetection for both timers. Then PathFinder.findPath,
which is the A* itself and shorter than you expect, and NodeEvaluator —
start with WalkNodeEvaluator, since two of the other three specialise it — for what
a block is to a mob. PathType is the 27 constants and their costs, and
Mob.getPathfindingMalus is how one mob disagrees with them.
MoveControl.setWantedPosition is the seam where a path becomes a direction.
Two doors: PathTypeCache, the 4,096-entry memo that makes the classification
affordable, and ServerLevel.sendBlockUpdated, the one call in the whole
system that runs the other way.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Damage and death
Verified against Minecraft 26.3 · Part VI · An arrow hits a player in full iron with Protection II: six damage becomes two, and if it kills, a message, a loot drop and a death screen.
The arrow lands, the screen goes red, the hearts drop by one, and somewhere
behind you the archer’s client plays its hit ding. Six damage left the bow and
about two reached your health, which is the part everybody knows. The part
nobody sees is what happens when a second arrow arrives four ticks later,
inside the flash. If more than ten ticks of invulnerability remain, that hit
is worth only its excess over LivingEntity.lastHurt: a weaker one returns
without dealing any damage, and a stronger one takes a partial
branch that clears an internal took full damage flag. That one flag gates
the damage-event broadcast, the knockback, the hurt sound and the red flash
alike. Health goes down and none of those happens — which means neither
ClientboundDamageEventPacket nor the red flash is a reliable “was hit”
signal. It is all in LivingEntity.hurtServer.
The cast
| class | what it decides | thread |
|---|---|---|
DamageSource | what hit and who: a direct entity, a causing entity, and — rarely — a position instead | built wherever the hit starts, on the Server thread |
DamageType | the message id, the difficulty scaling, the food cost, a player’s hurt sound, and the kind of death message | a dynamic registry entry, loaded from a data pack and synced to clients |
DamageTypeTags | almost every behavioural branch on the path below | read on the Server thread |
LivingEntity | the whole reduction pipeline, the i-frames, the flash, the two attribution references and death | the Server thread |
CombatRules | the two pieces of arithmetic — armour and enchantment protection | stateless statics |
CombatTracker | what killed you, and which entry gets the credit | the Server thread, cleared on its own timer |
ServerPlayer | PvP, the death screen packet, the death message, the inventory drop | the Server thread |
Entity | that Entity.hurtServer is abstract — there is no default behaviour to inherit | — |
Everything above is server-side, and LivingEntity declares no
Entity.hurtClient at all: no damage is ever calculated on a client. A
client does set health — from LivingEntity.DATA_HEALTH_ID, from
ClientboundSetHealthPacket, and to zero on the death event — but it never
decides a number, and the signature says so: Entity.hurtServer takes a
ServerLevel, so the side is enforced by the compiler rather than by a
convention. Which side is allowed to decide anything is
authority.
The number the arrow decides
AbstractArrow.onHitEntity builds the source first, because the number
needs it: EnchantmentHelper.modifyDamage takes the DamageSource as an
argument. It starts from AbstractArrow.baseDamage, 2.0 for an ordinary
arrow, runs
EnchantmentHelper.modifyDamage over the bow that fired it so Power raises
the base, then multiplies by the arrow’s current speed and rounds up with
Mth.ceil — so the damage that leaves the bow is an integer, and a slowing
arrow does less. AbstractArrow.isCritArrow adds a random bonus of up to
half the damage plus one. Six is a fresh arrow at full draw before that bonus,
which a full draw always earns, so six is the one draw in five that adds
nothing.
DamageSources.arrow then names two entities: the arrow as the direct
entity, the shooter as the causing one — or, when the arrow has no owner
left, the arrow itself in both roles. DamageSource.getWeaponItem reaches
through the direct entity, which is how Breach on the attacker’s weapon
reaches the victim’s armour calculation. A source can carry a position
instead, though far less often than you would guess: ordinary explosions do
not, and fall back to the direct entity in DamageSource.getSourcePosition.
Exactly three sources are genuinely positional, and only they put a position
on the wire through DamageSource.sourcePositionRaw —
DamageSources.badRespawnPointExplosion, /damage … at (DamageCommand),
and ExplodeEffect unattributed.
The DamageType behind the source is five fields — message id,
DamageScaling, food exhaustion, DamageEffects (which picks the hurt
sound, and only for a Player: Player.getHurtSound is its one caller)
and DeathMessageType — and it lives in a data pack. There are 51
keys in DamageTypes and 36 tags in DamageTypeTags, and almost every
behavioural branch below is tag-driven rather than type-driven. The
exceptions are worth listing because they are the whole list: thorns picks
its own secondary sound in LivingEntity.playSecondaryHurtSound, wind charge
is excluded from mob-anger attribution against an
EntityTypeTags.NO_ANGER_FROM_WIND_CHARGE entity, and a ServerPlayer
mid-dimension-change is invulnerable to all but DamageTypes.ENDER_PEARL.
Three gates before anything is computed
LivingEntity.hurtServer opens with three early returns: already
invulnerable, already dying, or a DamageTypeTags.IS_FIRE source against
MobEffects.FIRE_RESISTANCE — a mob-effect immunity sitting outside the
reduction pipeline entirely. Invulnerability itself is
Entity.isInvulnerableToBase (removed, the invulnerable flag or its timed
twin Entity.invulnerableTime, fire immunity, fall immunity) or an
enchantment-granted one through
EnchantmentHelper.isImmuneToDamage, which is how Frost Walker makes its
wearer immune to magma blocks.
Two subclasses get there first. ServerPlayer.hurtServer adds the PvP and
team check — ServerPlayer.canHarmPlayer refuses when the level forbids PvP
or the two share a team that disallows friendly fire — testing both a Player
causing entity directly and an AbstractArrow causing entity by asking it for
its owner. ServerPlayer.isInvulnerableTo adds the two conditions that have
no tag: mid-dimension-change, and a client that has not finished loading. Then
Player.hurtServer refuses a creative player unless the source is
DamageTypeTags.BYPASSES_INVULNERABILITY, and applies difficulty
scaling — halved and offset on easy, one and a half times on hard, zero on
peaceful — but only when DamageSource.scalesWithDifficulty says so. That
reads DamageScaling off the type, and DamageTypes.ARROW is
when_caused_by_living_non_player: a skeleton’s arrow scales and a player’s
does not. Player.isInvulnerableTo is also where GameRules.DROWNING_DAMAGE,
GameRules.FALL_DAMAGE, GameRules.FIRE_DAMAGE and
GameRules.FREEZE_DAMAGE live — switching one off makes a player immune
rather than making the damage smaller.
One number, eight steps, and no step that knows another
Past the gates the number goes down a chain of eight arithmetic steps — five multiplications and three subtractions — each owned by a different piece of the game, and none of them aware of the others.
flowchart TD
N0["the bow"]
N1["blocking"]
N2["freezing"]
N3["the helmet"]
N4["i-frames"]
N5["armour"]
N6["Resistance"]
N7["protection"]
N8["absorption, then health"]
N0 -- "6.0" --> N1
N1 -- "6.0" --> N2
N2 -- "6.0" --> N3
N3 -- "6.0" --> N4
N4 -- "6.0" --> N5
N5 -- "3.12" --> N6
N6 -- "3.12" --> N7
N7 -- "2.12" --> N8
The number rides the arrows: four steps in a row take nothing at all, and the whole reduction on a fully armoured victim happens in two.
Which piece of the game owns each of the eight, and what it does to the number:
| # | step | owned by | what it does to the number | leaves |
|---|---|---|---|---|
| — | the arrow leaves the bow | AbstractArrow | Mth.ceil of speed times base damage, Power already folded into the base | 6.0 |
| 1 | blocking | DataComponents.BLOCKS_ATTACKS, through LivingEntity.applyItemBlocking | subtracts what the component resolves for this angle and this damage type | 6.0 |
| 2 | freezing | EntityTypeTags.FREEZE_HURTS_EXTRA_TYPES | multiplies by five | 6.0 |
| 3 | the helmet | DamageTypeTags.DAMAGES_HELMET | multiplies by 0.75 | 6.0 |
| 4 | the i-frame window | LivingEntity.lastHurt | over ten ticks left, only the excess survives | 6.0 |
| 5 | armour | CombatRules.getDamageAfterAbsorb | effective armour over 25, floored at a fifth and capped at 20 — here 48 per cent off | 3.12 |
| 6 | Resistance | LivingEntity.getDamageAfterMagicAbsorb | five of twenty-five per level, and nothing at all at amplifier four | 3.12 |
| 7 | protection | CombatRules.getDamageAfterMagicAbsorb | the summed points over 25, capped at 20 — here 8 points, 32 per cent off | 2.12 |
| 8 | absorption | LivingEntity | spent before health is, then CombatTracker.recordDamage and LivingEntity.setHealth | 2.12 |
Before the first row there is one gate the chain never sees:
Player.hurtServer scales incoming damage by difficulty, and a player’s own
arrow is not scaled.
The first link is blocking, and shields are no longer a mechanism, only a
vocabulary. LivingEntity.applyItemBlocking asks the item being used —
through LivingEntity.getItemBlockingWith, which enforces
BlocksAttacks.blockDelayTicks — for a DataComponents.BLOCKS_ATTACKS
component, checks the damage type against BlocksAttacks.bypassedBy,
computes the angle between the source position and the victim’s head
rotation, and lets BlocksAttacks.resolveBlockedDamage sum the reductions in
the component’s own list that apply. BlocksAttacks.hurtBlockingItem then charges
durability — for a blocking Player only; a mob’s item never wears — and a
non-projectile block sends the blocker reeling through
LivingEntity.blockedByItem, which is how a Hoglin throws whoever blocked
it and a Ravager, unless it is roaring, either stuns itself while shoving
them or shoves them harder on a coin flip. An arrow with any piercing level skips all of
it before the angle is computed. ShieldItem still exists, the statistic is
still Stats.DAMAGE_BLOCKED_BY_SHIELD, and the axe-disables-shield rule
survives as LivingEntity.getSecondsToDisableBlocking — which Warden
overrides, and which reads the attacker’s Weapon component only while that
weapon is also their active item (the sword
swing)
— feeding Player.blockUsingItem and BlocksAttacks.disable. None
of it is shield-specific code any more.
The two multipliers after it almost never fire and are both pure tag lookups:
DamageTypeTags.IS_FREEZING against an entity in
EntityTypeTags.FREEZE_HURTS_EXTRA_TYPES multiplies by five, and
DamageTypeTags.DAMAGES_HELMET against a helmeted victim calls
LivingEntity.hurtHelmet and multiplies by 0.75.
Ten ticks in which nothing shows, and ten that protect nothing
LivingEntity.damageCooldownTime is set to 20 by a hit that lands in full and counted
down once a tick — from LivingEntity.baseTick for everything except a
ServerPlayer, and from ServerPlayer.tick for players, earlier in the tick.
LivingEntity.hurtDuration and LivingEntity.hurtTime are set to 10, so the
red flash covers half of it.
It is the flashing half that is invulnerable. The test is
LivingEntity.damageCooldownTime still being above ten, which is exactly the ten
ticks the flash is showing; once the counter falls to ten the next hit takes
the ordinary branch, lands in full, and resets both numbers. The second half
of what everyone calls the invulnerability window protects nothing at all.
Inside the first half, then, unless the type carries
DamageTypeTags.BYPASSES_COOLDOWN, the incoming damage is compared against
LivingEntity.lastHurt — what the last hit was worth — and only the excess
is applied. A hit that is not bigger returns before any hurt sound, damage
packet, knockback of the victim or combat entry. A hit that is bigger applies the difference and
clears a local took full damage flag, and everything downstream sits inside
a test of that flag: no ServerLevel.broadcastDamageEvent, no
Entity.markHurt, no LivingEntity.dealDefaultKnockback, no hurt sound, no
reset of the flash. So the first hit in each window is the one everyone sees;
a later, stronger one lands its excess with no flash for anyone watching,
though its lower health still goes out, and a victim’s own client, which
deduces a hit from the drop, flashes anyway.
LivingEntity.resolveMobResponsibleForDamage and
LivingEntity.resolvePlayerResponsibleForDamage sit outside that test and run
on the silent stronger partial hit as well as the full one — only the
weaker hit, which returns before them, leaves no damage and no attribution. They write
LivingEntity.lastHurtByMob and LivingEntity.lastHurtByPlayer (with its
hundred-tick LivingEntity.lastHurtByPlayerMemoryTime countdown), crediting a
tamed Wolf’s work to its owner. LivingEntity.lastDamageSource and
LivingEntity.lastDamageStamp are set beside them, but only when the hit
counted for something.
Armour, and why big hits punch through it
LivingEntity.actuallyHurt is where the number meets the victim’s gear.
LivingEntity.getDamageAfterArmorAbsorb first calls LivingEntity.hurtArmor
— which is empty on LivingEntity and overridden only by Player,
Horse and Wolf. A skeleton in full iron never wears its armour out. Where
it is implemented it routes to LivingEntity.doHurtEquipment: one durability
point per four damage, minimum one, per piece, and each piece must separately
be Equippable.damageOnHurt, damageable, and pass ItemStack.canBeHurtBy.
The armour formula, in the order it is applied
Then CombatRules.getDamageAfterAbsorb does the arithmetic. Effective armour
is the armour points, reduced by the incoming damage divided by (two plus a
quarter of toughness), then clamped between CombatRules.MIN_ARMOR_RATIO
(0.2) of the nominal points and CombatRules.MAX_ARMOR (20); the fraction taken
off is that effective armour over CombatRules.ARMOR_PROTECTION_DIVIDER, which
is 25. Big hits punch through armour by
design: the subtraction is what makes a 40-damage hit see less armour than a
6-damage one, and Attributes.ARMOR_TOUGHNESS is exactly the term that slows
it down. Full iron is 15 points and no toughness, so a 6-damage hit sees 12
effective armour, 48 per cent off, 3.12 left. Breach moves the resulting
fraction through EnchantmentHelper.modifyArmorEffectiveness first.
LivingEntity.getDamageAfterMagicAbsorb then applies Resistance and sums
EnchantmentHelper.getDamageProtection across every equipment slot.
Protection is not a class: it is
EnchantmentEffectComponents.DAMAGE_PROTECTION holding a conditional value
effect, confined to armour by its own slots declaration in the JSON rather
than by the helper, worth one point per level per piece — so Protection II on
four pieces is 8. CombatRules.getDamageAfterMagicAbsorb caps that sum at 20
and takes sum over 25 off. 3.12 becomes about 2.12.
Armour is capped twice and floored once. Effective armour never drops
below a fifth of nominal and never counts above 20, and protection points cap
at 20 as well — so armour alone tops out at 80 per cent reduction, protection
at 80 per cent of what is left, 96 per cent combined — so armour alone can
never take a hit to nothing. An effect can: Resistance at amplifier four
multiplies by zero.
What survives comes off absorption first; then, unless absorption took it all,
CombatTracker.recordDamage files a CombatEntry, and health takes the rest. Player.actuallyHurt
overrides the whole method to add food exhaustion (DamageType.exhaustion,
0.1 for an arrow) and the damage statistics.
Telling everyone, and what a block replaces
The arithmetic is over and the number has landed. What follows is an order, and two of its steps are the ones players notice going missing:
sequenceDiagram
participant AA as AbstractArrow
participant SP as ServerPlayer
participant CT as CombatTracker
participant SL as ServerLevel
participant CPL as ClientPacketListener
AA->>SP: hurtOrSimulate, the arrow's source and 6.0
SP->>SP: ServerPlayer.hurtServer, canHarmPlayer and teams
SP->>SP: Player.hurtServer, the creative gate and difficulty
SP->>SP: LivingEntity.hurtServer, the gates and the multipliers
SP->>SP: i-frames, where a partial hit clears the flag
SP->>SP: actuallyHurt, absorption first
SP->>CT: recordDamage, one CombatEntry
SP->>SP: then setHealth
SP->>SP: resolveMobResponsible<br/>ForDamage
SP->>SP: resolvePlayer<br/>ResponsibleForDamage
SP->>SL: broadcastDamageEvent, full hits and nothing blocked
SL-->>CPL: ClientboundDamageEventPacket, every tracker and the victim
SP->>SP: markHurt, then dealDefaultKnockback
SP->>SP: indicateDamage, skipped if anything was blocked
SP-->>CPL: ClientboundHurtAnimationPacket, that one player
SP->>SP: dead? then checkTotemDeath<br/>Protection
One lane for the victim, because ServerPlayer and LivingEntity are one
object: the three hurtServer messages at the top are one virtual call going
down the override chain. ServerLevel.broadcastDamageEvent comes before the knockback, not after — and the
last message is where the next section takes over.
ServerLevel.broadcastDamageEvent sends the type, three entity ids — the
victim, the causing entity and the direct one — and an
optional position to every tracking player and the victim, and it runs
before the knockback, not after. A successful block replaces it entirely:
if the blocking component absorbed anything, BlocksAttacks.onBlocked plays
the block sound and no damage event is broadcast at all, so a blocked hit
puts no flash on anyone else’s screen. Only then does Entity.markHurt queue the
velocity packet, and LivingEntity.dealDefaultKnockback compute a direction
(from the projectile for a projectile, from the source position otherwise)
for LivingEntity.knockback, which scales it by one minus
Attributes.KNOCKBACK_RESISTANCE. ServerPlayer.indicateDamage ends that
call, and is skipped when anything was blocked. The hurt sound comes after
all of it — after the death check, not with the flash.
Six packets carry the hit itself, beside the sound packets. ClientboundDamageEventPacket (type holder,
causing and direct entity ids, optional position) and
ClientboundSetEntityMotionPacket go to every tracker, and to the victim
when it is a player,
ClientboundHurtAnimationPacket and ClientboundSetHealthPacket only ever to
one player about themselves, ClientboundSetEntityDataPacket carries
LivingEntity.DATA_HEALTH_ID for a mob, and ClientboundEntityEventPacket
carries one byte — 3 for death, 60 for the poof, 35 for a totem. Inbound,
only the respawn command.
The damage amount never crosses the wire. The client picks a flash, and for
your own player a sound, from the type and infers magnitude from health — and only for your own
player, in LocalPlayer.hurtTo, the one place a hit is deduced from a health
drop. LivingEntity.handleDamageEvent sets LivingEntity.damageCooldownTime to 20
and the flash to 10 and plays the sound (which only your own player’s hurt
is heard from; everyone else’s comes from the server), touching health not at
all.
Death, or not
If health has reached zero, LivingEntity.checkTotemDeathProtection looks for
DataComponents.DEATH_PROTECTION in either hand — unless the source is
DamageTypeTags.BYPASSES_INVULNERABILITY, which is why /kill cannot be
totemed — and on a hit consumes one, sets health to one, applies
DeathProtection’s effects and broadcasts byte 35.
Otherwise LivingEntity.die runs, and its order matters:
flowchart TD
Z["health has reached zero"]
T{"DEATH_PROTECTION in a hand, and the source does not bypass?"}
TOT["consume one, health to one, byte 35"]
D["LivingEntity.die: kill credit, then LivingEntity.handleKillingBlow"]
V{"no causing entity, or Entity.killedEntity agrees?"}
L["the death game event, LivingEntity.dropAllDeathLoot, the wither rose"]
B["the entity-event byte, to every watcher"]
P["Pose.DYING"]
Z --> T
T -- "yes" --> TOT
T -- "no" --> D
D --> V
V -- "yes" --> L
V -- "no" --> B
L --> B
B --> P
The diamond in the middle is a veto one other entity holds over this one’s loot. The two boxes below it are what the veto cannot reach: the byte goes to every watcher either way, and the pose is set after the byte, not with it.
Kill credit is read
from the attribution references written a few lines earlier, and
LivingEntity.handleKillingBlow sets LivingEntity.dead. Then — with a causing entity, only if
Entity.killedEntity on it agrees; with none, unconditionally — the death
game event fires,
LivingEntity.dropAllDeathLoot runs and the wither rose is planted. The
entity-event byte sits outside that veto and goes out to every watcher
before Pose.DYING is set — the byte first, the pose after.
Loot needs a recent player. LivingEntity.dropAllDeathLoot reads killed by
a player off LivingEntity.lastHurtByPlayerMemoryTime still being above
zero, so an attribution older than a hundred ticks costs the loot context its
LootContextParams.LAST_DAMAGE_PLAYER and its luck, and costs the experience
drop entirely unless the mob is an always-dropper. LivingEntity.shouldDropLoot
gates loot on GameRules.MOB_DROPS and on not being a baby — but
Monster.shouldDropLoot drops the baby condition, which is why baby zombies
and piglins do drop. LivingEntity.shouldDropExperience mirrors it. Only
LivingEntity.dropEquipment is outside both.
The death screen, and what the client does alone
ServerPlayer.die never calls up. It reimplements the sequence, so
LivingEntity.handleKillingBlow never runs and LivingEntity.dead stays
false on a dead player for the whole death screen. It reads the message
from the combat log at the top and calls CombatTracker.recheckStatus at the
bottom, so the log survives long enough to be read.
GameRules.SHOW_DEATH_MESSAGES gates more than the chat line: with the rule
off the message is never assembled (text
components
owns the assembling) and ClientboundPlayerCombatKillPacket
carries an empty component, so the death screen still opens and says
nothing. ServerPlayer.die also forgives neutral mobs under
GameRules.FORGIVE_DEAD_PLAYERS, drops the inventory through
Player.dropEquipment unless GameRules.KEEP_INVENTORY, and calls
ServerGamePacketListenerImpl.markClientUnloadedAfterDeath. DeathScreen
opens unless GameRules.IMMEDIATE_RESPAWN, and the object that comes back —
a new ServerPlayer wearing the old one’s id and connection — belongs to players and
sessions.
Two things then happen without a packet of their own. The client kills mobs on its own,
from one byte: LivingEntity.handleEntityEvent for byte 3 sets a non-player
entity’s health to zero and runs LivingEntity.die locally, so the twenty
tick animation in LivingEntity.tickDeath is client-driven, not a
consequence of a health update. And CombatTracker clears itself — after
CombatTracker.RESET_DAMAGE_STATUS_TIME out of combat or
CombatTracker.RESET_COMBAT_STATUS_TIME in it. Four places call the check: a
twenty-tick timer in LivingEntity.tick, the top of
CombatTracker.recordDamage, LivingEntity.remove, and ServerPlayer.die — so a
hit after a long lull discards the old log before filing its entry.
Who gets the credit for a fall
The log is not read in order, and the rule that reads it is the reason for
was doomed to fall by. CombatTracker.getMostSignificantFall walks the
entries looking for the biggest fall, and when it finds one it credits the
entry before it — the hit logged just before the fall’s own entry — taking
the fall entry itself only when the fall is the first thing in the log. It
keeps a second candidate beside that, the biggest-damage entry that carries a
FallLocation. Then the threshold: the fall counts only if it was more than
five blocks, and the alternative only if its damage was more than five;
below both, the tracker returns nothing and the death message falls back to
the ordinary one for the killing blow. So a skeleton’s arrow and then a
four-block fall that kills you is hit the ground too hard while trying to
escape the skeleton (the source’s own message, crediting the last mob that
hurt you), and the same arrow before a ten-block fall is doomed to fall by
the skeleton — same two entries, one number apart. The FallLocation is what turns the credited entry into the
wording (fell off some vines, fell off a ladder, fell while climbing),
and building the sentence out of it belongs to text
components.
Everything that calls it
Entity.hurt and Entity.hurtOrSimulate are deprecated final wrappers over
the abstract method: the second picks a side and returns Entity.hurtClient
off a client level, the first does nothing there. Entity.hurtClient has
ten declarations counting the base, and every one only ever answers did
this connect — including RemotePlayer, which returns true unconditionally
so a client-side arrow can play its own effects without knowing any numbers.
Environmental damage is ticked from more places than base tick suggests.
Fire (once per twenty fire ticks) is in Entity.baseTick, lava (four at a
time) in Entity.lavaHurt, and suffocation, world-border and drowning damage
in LivingEntity.baseTick. Freezing and cramming are not: freezing is in
LivingEntity.aiStep every forty ticks, and cramming inside
LivingEntity.pushEntities, called near the end of the same method under
GameRules.MAX_ENTITY_CRAMMING, just before the drowning a water-sensitive
mob takes in water or rain.
Twenty-two classes with no pipeline at all
Everything above this line is LivingEntity’s, and none of it is inherited.
Entity.hurtServer is abstract: there is no default behaviour anywhere in
the tree, so every branch answers for itself. Fifty-five files override it
— a fifty-sixth, Entity itself, only declares it — thirty-three of them
LivingEntity and its descendants, ArmorStand among them, a LivingEntity despite
having no AI (entity
anatomy).
The other twenty-two are not living entities at all, and none of them runs
armour, i-frames, absorption, the combat tracker or the death sequence itself.
Six patterns cover all twenty-two, and the sharpest are the ones that read the
damage number — only four classes do. ItemEntity and ExperienceOrb keep a
plain integer of health and subtract from it. VehicleEntity adds damage ×
10 to an accumulator that loses one a tick and breaks past 40, which is why
any hit over four damage breaks a minecart at once and smaller ones must land
close together, and MinecartTNT
inherits that accumulator after its own override has looked at the arrow rather
than the number. For the other
eighteen the answer is a yes or a no: ten do nothing whatever, two resend their motion and nothing else,
five are destroyed by one hit of any size (an unfixed frame holding something loses only its item to the first) — an EndCrystal among them, and it
is immune to the EnderDragon that eats it — and EnderDragonPart reads
nothing, forwarding the whole call, number included, to its parent. Which class
does which is the non-living damage
table.
For a 1.21-era reader. The hurt cooldown is
LivingEntity.damageCooldownTime. invulnerableTime onEntityis now a separate timer of full invulnerability that no hit sets: a skeleton trap gives the horses it adds and every skeleton rider sixty ticks of it, a cushion broken by lightning gives the item it drops twenty, a conversion carries it over, andEntity.isInvulnerableis true while it runs.
Where to look
LivingEntity.hurtServer is the whole first half of this page in one method,
and the two overrides that reach it first — ServerPlayer.hurtServer and
Player.hurtServer, over the abstract Entity.hurtServer — are the gates.
Then LivingEntity.actuallyHurt and the two CombatRules statics it reaches,
which are short and settle every argument about armour. DamageSource and DamageType are the what hit you, and
DamageTypeTags is where nearly every branch on the page lives, so
read the tag file before the type file. For death:
LivingEntity.die and then ServerPlayer.die, which does not call it, with
CombatTracker and FallLocation for the message. Two doors:
BlocksAttacks, the component that replaced shield code, and VehicleEntity,
the clearest of the twenty-two branches that inherit none of this. For the
numbers as attributes — armour, toughness, knockback resistance — see
attributes.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
VII · Items and inventories
Verified against Minecraft 26.3 · Part VII · The things you carry: what a stack is, what happens when you use one, how two machines agree about a chestful of them, and the three engines that make them out of data.
A block is a position in a grid and an entity is a thing in the world. An
item is neither: it is a stack, and a stack only exists inside something
else — a hand, a slot, a chest, a recipe grid, a dropped ItemEntity, a
packet. A player recognises the part by the moments that container is showing:
the shift-click that lands before anything has answered it, the bow that fires
when you let go rather than when it finished drawing, the chest whose contents
appear a tick late, the dungeon chest that is empty until somebody opens it.
Two of those four are a client guessing ahead of the server, the shift-click and
the bow (containers and menus,
using an item);
the tick-late chest is not a guess at all, but a broadcast that missed its phase,
and the dungeon chest is a roll the server has not made yet. Telling those apart
is most of what this part teaches. An
item is the thing in the game that is never simply somewhere — so almost every page here is about a container, and about which of the two programs is allowed to
believe what it holds.
The shape of the part
More than in most parts, an item is where some other system surfaces:
BlockItem belongs to Part V · Blocks, BucketItem to Part IV · The world,
SpawnEggItem to Part VI · Entities, AttackRange to Part VIII · The player,
and Equippable to whatever wears it, armour and saddle alike. In each the item is only the
handle.
Three subjects go out the same door. The part stops at the slot: what a player’s own inventory is belongs to player anatomy, how a held stack picks the model you see belongs to models and atlases, and the reason a container click is not on the prediction ledger — no sequence number, no window — belongs to prediction and acknowledgement.
What is left is two tiers. The first three pages are the vocabulary — what a stack is, what using one does, and how a set of them is kept in agreement across the wire. The last five are three engines built out of one pattern — a registry of kinds, a dispatch codec, a data file naming one (data-driven types) — that produce or decorate stacks: recipes, enchantments and loot tables, with enchanting and contexts and predicates beside the last two.
The engines lean on the vocabulary, and where they touch each other (one of enchanting’s five paths runs through loot functions, and an enchantment’s conditions are contexts and predicates’ machine) each page links the other’s machine rather than teaching it. Contexts and predicates is the outlier: its subject is not a stack at all, but the question engine the other engines happen to run on, and it can be watched first.
Every arrow below is what the next page can now assume, not what its code calls. Read down a chain and nothing is missing; read across two and what one owes the other is a link, not a lesson.
flowchart TD
subgraph V["the vocabulary"]
IS["1 · Items and stacks"]
UI["2 · Using an item"]
CM["3 · Containers and menus"]
end
subgraph E["the engines"]
RE["4 · Recipes"]
EN["5 · Enchantments"]
EC["6 · Enchanting"]
CP["7 · Contexts and predicates"]
LO["8 · Loot tables"]
end
IS -- "a stack is a diff over a prototype" --> UI
UI -- "and a slot is where one lives" --> CM
CM -- "an arrangement in a grid" --> RE
IS -- "a modifier on the stack" --> EN
EN -- "and how one gets onto a stack" --> EC
CP -- "which needs no stack at all" --> LO
The two tiers of the part, numbered to the watch order. Every arrow means “and now you can assume this”, so contexts and predicates, the one page after the first with no arrow into it, is one you can watch whenever you like.
Before you start
Data
components
is the hard prerequisite: a stack is an item plus a component patch, and this part
never re-teaches the component system. Codecs, NBT and
JSON for the four
paths a stack travels, and identifiers and
registries and
the resource
system
for where recipes, enchantments and loot tables come from and when. They come from two different places, and
the difference bites: recipes and loot tables are both in the reloadable
registry layer, so /reload rebuilds both — while enchantments are
a world-load dynamic registry that /reload never
re-reads, their
tags aside.
Three ordering facts matter more than they look. The server tick drains the packet queue before any level ticks, and a click is answered inside that drain; the level tick decides when a menu’s other changes are broadcast (a pickup’s excepted, which the connection phase sends), which is what makes a hopper’s delivery visibly late; and block interaction is how a chest gets opened in the first place, which is where two of these pages start.
Watch in this order
The first three in order, then the engines in any order the arrows allow; contexts and predicates needs none of the first three and can come first.
- Items and stacks — an
Itemholds almost no data and anItemStackholds a diff, and everything else follows: what makes two of them the same stack, what one may legally hold, and why a client never predicts the wear an item takes. - Using an item — a meal and a bow, which are one machine read two ways. The client’s countdown never stops at zero: the meal ends when a single byte arrives, and the bow ends when you let go.
- Containers and menus — a shift-click out of a chest. One packet goes up, nothing comes back, and agreement is silence, because the server adopted the client’s claim as its new baseline.
- Recipes — eight planks and an empty middle. No recipe ever crosses the wire, and yet the client holds the whole contents of every recipe it has unlocked.
- Enchantments — there are no enchantment subclasses. Fire Aspect is a data-pack record whose “melee only” rule is one loot condition, the burn that follows belongs to something else entirely, and the three enchantments players talk about most are driven from the other end, only one of them with no effect component at all.
- Enchanting — the five paths that change what an item is enchanted with, one of which runs backwards. The seed is per player, saved, and sent to the client, which is why the Standard Galactic gibberish is stable and why an anvil never changes what the table is offering.
- Contexts and predicates — the engine that
answers is this true here. Seventeen of its thirty-one parameter sets
never roll a loot table at all: five of them belong to enchantment
effects, five are built for a number or a set of slots, one exists only to
validate against, and the rest are
/execute if predicate, entity selectors, advancement triggers and villager trades. - Loot tables — the worked example, and the part’s last lecture. A dungeon chest is genuinely empty on disk, and the first thing to read it — a hopper will do, and so will breaking it — commits the roll, with no player in it.
Watched as lectures, five and six are the pair to keep together: what an enchantment is and how you get one. Seven and eight are the other pair, and seven is the one Part XIII’s landing page comes back for.
Where the part stops
The part is 581 files and 49,245 lines across world/item,
world/inventory and world/level/storage/loot, and
27% of the part’s lines are named on no page in the book. Much of that is an answer
rather than a gap: it is the families each page names once and does not enumerate. world/item’s sixty-seven Item subclasses are, all but two, kept for a
behaviour hook no component can express; the twenty-eight menu classes in
world/inventory are one machine with different slot
lists; the forty-two loot functions and twenty loot conditions are one shape
each; and the special crafting recipes are the nine the recipe book never shows.
Four things here are explained nowhere: villager trading (VillagerTrades,
the bootstrap behind the trade files, is alone the part’s fifth-largest class —
the loot machinery a trade runs on is covered, the trades themselves are not),
brewing, a recipe kind the recipes page names and does not follow, the
creative tabs, and armour identity and trims. A second
edition should take them; this one names them and says so.
Reference this part uses
Three were written for it. The weapon
helpers — the seven Item.Properties
methods that make a weapon and the forty-two items built by one; enchantment
hooks — every EnchantmentHelper
entry point with the classes that call it, which is where most of the game meets the enchantment system. Loot context parameter
sets — all thirty-one, with the
keys each one requires and allows. Then the catalogue behind the part’s hard prerequisite, data
components — every component type in DataComponents with what it holds; packets for the container and recipe
traffic; registries for where each engine’s elements live; naming
drift for the predicate library’s move out of
critereon; and diagram lanes.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Items and stacks
Verified against Minecraft 26.3 · Part VII · A diamond pickaxe sits in a hotbar slot, is compared against its neighbours, is sent to a client, and finally loses its last point of durability.
A diamond pickaxe is in your hotbar. The Item behind it,
Items.DIAMOND_PICKAXE, is a single object shared by every diamond pickaxe
that has ever existed on this server, and it holds four fields: a description
id, a crafting remainder, a feature-flag set, and its own registry holder —
Item.descriptionId, Item.craftingRemainingItem, a FeatureFlagSet in
Item.requiredFeatures, and Item.builtInRegistryHolder. Not
the stack size. Not the mining speed. Not the durability. All of that is data
components — and they do not live on the Item either. They live on the
item’s Holder.Reference in BuiltInRegistries.ITEM, as a prototype map
built at the first data-pack load and rebuilt at every one after (data
components),
and the stack borrows that map read-only and stores only the ways it
differs from it. This page is the object in the slot rather than the
component system behind it: what an ItemStack is made of, what makes two of
them the same stack, what a stack may legally hold, and what happens to one
that runs out of durability. That last is the odd one out in a part where
almost everything is predicted locally and corrected afterwards.
A client never even guesses at the durability an item spends — the method that spends it demands a ServerLevel outright, and the convenient overloads
that take a LivingEntity instead look its level up and silently do nothing
when the answer is a client’s.
The cast
| class | what it decides | thread |
|---|---|---|
Item | the behaviour hooks, and four fields that are not components | both |
Item.Properties | the builder — which produces an initializer, never a component map | class-init, wherever the bootstrap runs |
Holder.Reference | where an item’s default components live, and whether they exist yet | written by the thread loading the world, on the Server thread at each /reload, and on the Render thread for a joining client |
DataComponentInitializers | the pile of pending default maps, one entry per registered item | built on the worker pool on the server, on the Render thread on a joining client |
ItemStack | a holder, a count, a pop time and a patched map — the mutable thing in a slot | both |
PatchedDataComponentMap | prototype plus patch, and the copy-on-write flag that makes copying a stack cheap | wherever its stack is |
ItemStackTemplate | the immutable stack: what a stack looks like inside a component, a particle or a recipe | both |
ItemEntity | a stack that is an entity, with a five-minute clock and a merge rule | Server, mirrored on the Render thread |
Four fields, and only one of them is really data
ItemStack is a final class holding exactly four things: two ints, a registry
holder, and the interesting one.
classDiagram
class ItemStack {
int count
int popTime
Holder~Item~ item
PatchedDataComponentMap components
}
class Item {
String descriptionId
ItemStackTemplate craftingRemainingItem
FeatureFlagSet requiredFeatures
Holder.Reference builtInRegistryHolder
}
class Reference["Holder.Reference, in BuiltInRegistries.ITEM"] {
the Item
the item's default DataComponentMap
}
class PatchedDataComponentMap {
the prototype, borrowed
the patch, owned
}
ItemStack *-- PatchedDataComponentMap : the only mutable half
ItemStack --> Reference : every stack of this item, the one holder
Reference --> Item
PatchedDataComponentMap ..> Reference : reads the defaults, never writes them
ItemStack’s four fields and Item’s four, and the arrows between the classes. The dotted one is the shape of the
whole system — a stack reads defaults it can never touch.
A stack does not own its defaults and cannot change them: it points at a
holder, and the holder owns one DataComponentMap shared by every stack of that item in both
programs (a reload rebinds it, and a stack made before keeps the map it was made
with). What the patch is made of, and why copying a stack is cheap, belong to data
components.
The holder field the figure calls item is read through
ItemStack.typeHolder, and it answers Items.AIR’s holder rather than null
for an empty stack, which is why ItemStack.getItem never returns null
either. The
pop time — ItemStack.popTime — is the odd one out: it is the five-tick
squeeze the hotbar icon does
when something lands in it, set to 5 by Inventory when a stack grows or a damaged item lands in an empty
slot, and by
ClientPacketListener.handleContainerSetSlot when a slot update makes a
hotbar stack larger, counted down by ItemStack.inventoryTick on both
sides, and read by Hud.extractSlot, which scales the icon while it is above
zero and hands the drawing to GuiGraphicsExtractor either way. It is
ordinary shared state that only the client has any use for — and
ItemStack.copy carries it across, so the animation survives being copied
into a menu slot.
Item.Properties is the builder used at class-init, and its output is not a
component map. Item.Properties.component and every convenience over it —
Item.Properties.stacksTo, Item.Properties.durability,
Item.Properties.food, Item.Properties.tool, Item.Properties.spear,
Item.Properties.equippable, Item.Properties.useCooldown — fold one more
step onto a DataComponentInitializers.Initializer, a function that will be
run against a DataComponentMap.Builder later, with a
HolderLookup.Provider in hand. The ones that make a weapon are a different seven — Item.Properties.tool,
Item.Properties.spear and five named for what they make — and between them they
build forty-two items out of a registry of over a thousand
(the weapon helpers).
Between the two halves of that arrangement an Item is a live object with no
components at all: the constructor registers an initializer and the map is not
built until a reload builds it, so Item.components throws until then and
Item.CODEC_WITH_BOUND_COMPONENTS exists to refuse an item whose components
are not bound yet. When and on which thread that happens, on the server and on
a joining client alike, is data
components.
What copying a stack costs, and what crosses the wire
Two properties of that borrowed map do work on the rest of this page, and
both belong to data components.
The patch is sanitised on every write, so it can never hold a value the
item already had — which is what makes the equality table below behave. And
the patch map is shared until someone writes to it: ItemStack.copy hands
out the same map and sets a copy-on-write flag, and the fork happens on the
first mutation. Copying a stack is therefore something the game does without
thinking about it, and it does — menus, recipes and ServerPlayerGameMode.destroyBlock all copy constantly, and
each copy allocates two small objects and no component data.
The wire form falls out of the same shape and is the second of the four serialisations codecs, NBT and
JSON lays side
by side: ItemStack.OPTIONAL_STREAM_CODEC writes the count, the item holder
and the patch, never the prototype, because the receiver has the same
prototype bound to the same holder. A count of zero is the whole encoding of
an empty stack.
When two stacks are the same stack
Five static methods on ItemStack answer five different versions of that
question, and menus, recipes and the first-person hand each want a different
one.
| method | compares | used for |
|---|---|---|
ItemStack.isSameItem | the item holder only | is this the same kind of thing |
ItemStack.isSameItemSameComponents | the item, then the whole PatchedDataComponentMap | stacking, and every are these interchangeable test |
ItemStack.matches | the count as well | container synchronisation (containers and menus) |
ItemStack.matchesIgnoringComponents | everything except the component types a predicate excuses | the held-item swap animation |
ItemStack.hashItemAndComponents | the item’s hash and the effective component map’s | keying stacks in hash sets |
The second row is where the borrowed map pays off. Comparing two stacks of the same item amounts to comparing their patches, and because a patch never holds a value equal to the prototype’s, two pickaxes with the same damage are equal whether one reached that state by being set explicitly or by never being touched.
The fourth row exists for one component. DataComponents.DAMAGE is the only
component type in the game declared with
DataComponentType.Builder.ignoreSwapAnimation, and
FirstPersonHandsAndItems.shouldInstantlyReplaceVisibleItem passes exactly
that flag as the predicate — which is why a pickaxe losing a point of
durability does not re-play the lower-and-raise animation. And a trap sits
under all five rows: ItemStack.EMPTY is a singleton but is not identified
by reference, because ItemStack.isEmpty also answers true for Items.AIR
and for any count at or below zero.
Two validators, one rule, two spellings
Durability and stackability are mutually exclusive, and the game says so twice — in different places, at different times, against different components.
| the reload validator | ItemStack.validateStrict | |
|---|---|---|
| installed by | Item.Properties.finalizeInitializer | — |
| rejects | DataComponents.DAMAGE on a stackable item | DataComponents.MAX_DAMAGE on a stackable item, and a count over the maximum |
| runs inside | DataComponentMap.Builder.build | ItemInput, ItemStack.applyComponentsAndValidate, and ItemStackTemplate.create and ItemStackTemplate.apply through one private step they share |
| when | at reload, on the worker pool (a joining client’s on the Render thread) | when a command, a template or a component patch builds a stack |
| on failure | throws, failing the reload | depends on the caller: ItemInput throws a command syntax error, the other two log and yield ItemStack.EMPTY or restore the previous patch |
Neither is reached from a network decode. Exactly one serverbound packet
in the protocol carries an ItemStack at all, the creative slot’s, and it is
proved a third way — re-encoded rather than validated (codecs, NBT and
JSON).
The strict validator reaches one level into a stack’s contents and no further.
ItemStack.validateContainedItemSizes runs inside ItemStack.validateStrict
over DataComponents.CONTAINER, DataComponents.BUNDLE_CONTENTS and
DataComponents.CHARGED_PROJECTILES, checking each contained stack’s count
against its own maximum — and it does not re-run the full validation there, so nesting is not followed;
a shulker box full of impossible stacks is caught by a command, and not at the
creative slot’s door, which never runs the strict validator at all. The bundle is the one that gets a second test of its own: a BundleContents whose
weight cannot be computed fails too, though one merely over its capacity passes.
An item, a count, some components — said three ways
ItemInstance is the read-only contract the contents check is written against: ItemInstance.count, ItemInstance.getMaxStackSize, and — through
TypedInstance and DataComponentGetter — five TypedInstance.is overloads
for tags, holder sets, raw items, holders and resource keys, to which
ItemStack.is adds a sixth taking a predicate. Its default
ItemInstance.getMaxStackSize answers 1 when
DataComponents.MAX_STACK_SIZE is missing — which for a bound item never
happens, because the common set puts 64 there.
Two classes implement it. ItemStack is the mutable one that lives in slots.
ItemStackTemplate is an immutable record of a Holder<Item>, a count and a raw DataComponentPatch — one the template itself never sanitises against a
prototype, so a template read from data can carry a component value equal to the
item’s own default, which no ItemStack can. It is what a stack becomes when it is stored inside something
else: ItemContainerContents (so a shulker box’s contents are
templates, not stacks), BundleContents, ChargedProjectiles,
ItemParticleOption, HoverEvent, UseRemainder, the recipe classes, and
Item’s own crafting remainder. Its constructor refuses a count of zero or
Items.AIR, so there is no empty template; but
ItemStackTemplate.create and ItemStackTemplate.apply, which materialise a
real stack, run ItemStack.validateStrict on the result and answer
ItemStack.EMPTY with a log line rather than throwing.
A pickaxe’s last point of durability
Durability is not one component: ItemStack.isDamageableItem demands
DataComponents.MAX_DAMAGE present, DataComponents.UNBREAKABLE absent, and
DataComponents.DAMAGE present. Take a diamond pickaxe one block short of
breaking, and mine that block.
ServerPlayerGameMode.destroyBlock copies the held stack before touching it
— that copy is what Block.playerDestroy later hands the loot table, so the
drops are decided by the tool as it was — then calls ItemStack.mineBlock,
which delegates to Item.mineBlock and awards Stats.ITEM_USED if the item
claims the block. The base Item.mineBlock is pure component work: it reads
DataComponents.TOOL, does nothing without one, and damages the stack by
Tool.damagePerBlock only on a server level, only when that number is above
zero, and only when the block’s destroy speed is not zero — so instant-break
plants cost a tool nothing, and neither does a tool that declares no per-block
damage.
ItemStack.hurtAndBreak is the way in for almost everything, and the overload
that does the work demands a ServerLevel outright. The overloads taking a LivingEntity
pattern-match on the entity’s level and silently do nothing on the client,
which is why a client never predicts the durability an item spends. The amount then goes through
EnchantmentHelper.processDurabilityChange
(enchantments), which is how Unbreaking turns a point of
damage into no damage at all, and a player with Player.hasInfiniteMaterials
short-circuits to zero before even that. If anything survives, the stack fires
CriteriaTriggers.ITEM_DURABILITY_CHANGED for a real player, writes the new
DataComponents.DAMAGE, and — this being the last point — finds
ItemStack.isBroken true, shrinks itself by one, and calls the break hook
it was handed. For equipment that hook is LivingEntity.onEquippedItemBroken,
which strips the item’s attribute modifiers and broadcasts an entity event
(47 for the main hand); ServerPlayer.onEquippedItemBroken adds
Stats.ITEM_BROKEN on top.
What the client is told, and what it draws
The client is told in one byte. LivingEntity.handleEntityEvent turns event
47 into LivingEntity.breakItem, which plays DataComponents.BREAK_SOUND
from the stack still in that slot and spawns five item particles; the empty
slot itself arrives separately, as a container update. Two siblings round the
family out: ItemStack.hurtWithoutBreaking clamps one short of the maximum,
and ItemStack.hurtAndConvertOnBreak transmutes rather than vanishing.
What the player watched was three methods on Item.
Item.isBarVisible is is this stack damaged, Item.getBarWidth scales the
damage over the thirteen pixels Item.MAX_BAR_WIDTH names, and
Item.getBarColor
sweeps a hue from green to red. GuiGraphicsExtractor draws the two-pixel bar
under the icon from those three answers and nothing else.
All three read the client’s own copy of the stack, and the client never wrote to it. So the bar does not creep as you mine: it sits at whatever the last slot update said, and jumps when the next one arrives. The one point of durability you just spent is invisible until the server sends the slot back.
The tick a stack gets, and the stack that is an entity
ItemStack.inventoryTick runs on both sides and does exactly one thing there:
decrement the pop time. It forwards to Item.inventoryTick only for a
ServerLevel — the hook’s parameter is declared as one, so it cannot be
otherwise — and exactly two items override that hook, CompassItem and
MapItem. It has two callers: Inventory.tick, from Player.aiStep, walks
the thirty-six ordinary slots and tells the selected one it is the main hand;
EntityEquipment.tick, from LivingEntity.aiStep, walks the worn and held
slots of every living entity. A stack that leaves an inventory altogether
becomes an ItemEntity, which keeps it in a synched data entry
(synched entity data) rather than a
plain field, counts up to a 6000-tick lifetime, and folds itself into
neighbours through ItemEntity.mergeWithNeighbours — a merge that keeps the
smaller of the two ages, so a fresh drop rejuvenates an old one. Blocks
reach it through Block.popResource
(block breaking).
The seventy-nine classes, and why sixty-seven of them are almost empty
world/item’s own directory holds seventy-nine classes beside its records,
enums and interfaces. Two of them are Item and ItemStack and ten are
helpers such as Items and ItemCooldowns; the other sixty-seven are one Item subclass each, and all but two exist for the
same reason — a behaviour hook that no component can express (BannerItem adds
only an accessor, and ProjectileWeaponItem is the bow’s and the crossbow’s
shared base). BoneMealItem, HoneycombItem,
EnderEyeItem, DebugStickItem, LeadItem and thirty-four more override
Item.use, Item.useOn or Item.interactLivingEntity and hold no state of
their own. Axes, shovels and hoes are plain Items: stripping, path-making
and tilling are one component, DataComponents.BLOCK_TRANSFORMER, that the
base Item.useOn reads and runs. That is why a registry of over a thousand
items has so few classes behind it (the class
hierarchy).
What this page hands off
Everything a stack does when a player holds down the use key — the prediction, the countdown, the consumable and cooldown components, the completion packet — is the next lecture: using an item. How two machines agree about a set of stacks in a screen is containers and menus; the component system itself is data components, catalogued in the components reference. And how an item picks the model, texture and tint you see in the slot is not this part’s subject at all: it is Part XI’s, in models and atlases.
For a 1.21-era reader. AxeItem, ShovelItem and HoeItem are gone:
DataComponents.BLOCK_TRANSFORMERdoes their work, naming aBlockTransformerfrom a data registry. ItemInHandRenderer is gone too;FirstPersonHandsAndItemsdecides the swap animation.
Where to look
Open ItemStack first and read its four fields; everything else on this page
is a consequence of them. PatchedDataComponentMap beside it is where the
copy-on-write flag and the sanitising live, and it is the shortest way to see
why two pickaxes with the same damage are equal.
Then Item and Item.Properties, in that order, for the gap between a live
item and its components: the builder’s output is a
DataComponentInitializers.Initializer, and where it eventually lands is
Holder.Reference in BuiltInRegistries.ITEM. Items is the roll call of
what was built.
For the durability trace, ServerPlayerGameMode.destroyBlock is the entry
point and ItemStack.hurtAndBreak is where it ends up. For the immutable
twin, ItemStackTemplate; for the contract both stacks answer,
ItemInstance. ItemEntity is the stack that left the inventory, and
Inventory.tick and EntityEquipment.tick are the two walks that tick the
stacks that did not.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Using an item
Verified against Minecraft 26.3 · Part VII · A player holds the use key on a piece of cooked beef, then holds it on a bow — one countdown, two endings.
You hold the use button on cooked beef and thirty-two ticks later you have
eaten it. You hold the same button on a bow and nothing happens at all until
you let go. These are the same machine: Item.use starts both,
LivingEntity.useItemRemaining counts down on the client and the server
for both, ItemStack.onUseTick runs every tick on both sides, and
LivingEntity.stopUsingItem ends both. What differs is whether the countdown ever reaches zero, and in play only the
meal’s does: a meal is
thirty-two ticks long and a bow is set to 72000, an hour. And the client’s
countdown does not stop at zero on either. The meal ends because one byte
arrives from the server — a ClientboundEntityEventPacket carrying
EntityEvent.USE_ITEM_COMPLETE, which is the byte 9, the number this page
uses for it from here on. The bow’s shot lives only in its release, so it is fired by a
ServerboundPlayerActionPacket instead: the same packet, carrying the
same action, that the meal would read as the player changed their mind.
The cast
| class | what it decides | thread |
|---|---|---|
Minecraft | which key edge starts a use, and which one ends it | Render |
MultiPlayerGameMode | the client’s own copy of the use, and the packet that reports it | Render |
ServerGamePacketListenerImpl | which handler each packet reaches, and whether the rotation is snapped first | Server |
LivingEntity | the countdown, the synched flags, and both endings | both |
ItemStack | the dispatch into the item, and the after-use side effects | both |
Item | Item.use, Item.getUseDuration, Item.releaseUsing, Item.useOnRelease | both |
Consumable | everything the meal does, on both sides | both |
ProjectileWeaponItem | ammo, spread and the arrow — the counting on both sides, the spawning only on the server | both, but only the server’s counts |
The block-target branch of a use — right-clicking a chest rather than the
air — leaves at MultiPlayerGameMode.useItemOn and belongs to
block interaction;
the stack itself is items and
stacks.
The two paths, side by side
| the meal | the bow | |
|---|---|---|
what Item.use does | the default body finds DataComponents.CONSUMABLE and calls Consumable.startConsuming | BowItem.use overrides it, checks Player.getProjectile, calls LivingEntity.startUsingItem |
| the refusal | Consumable.canConsume asks Player.canEat, for food only | no arrow anywhere and no infinite materials — InteractionResult.FAIL |
Item.getUseDuration | Consumable.consumeTicks, 32 for Consumables.DEFAULT_FOOD | 72000 — an hour, and BowItem’s own override |
what ItemStack.onUseTick does | particles and the chew sound, on both sides | nothing — BowItem does not override Item.onUseTick |
| how it ends | the count reaches zero on the server | the use key comes up on the client |
| the packet that ends it | ClientboundEntityEventPacket, downward | ServerboundPlayerActionPacket, upward |
| is the ending answered | yes — that is the answer | no, nothing answers a release |
Item.releaseUsing | not overridden, returns false, so letting go simply abandons the meal | BowItem.releaseUsing is where the whole shot lives |
ItemStack.useOnRelease | false | also false |
| what the client predicts | the start and every tick of the meal, then the ending replayed when event 9 arrives | the animation, and the release on its own copy, which shoots nothing |
ItemStack.useOnRelease is not the row that decides it
The ItemStack.useOnRelease row is the one that looks wrong. It is the
obvious name for “this item is finished by letting go” — but it only
delegates, and the hook it
delegates to, Item.useOnRelease, has exactly one override in the tree,
CrossbowItem’s. The bow and the trident take the default false. They are
release-ended not because a predicate says so, but because their
Item.getUseDuration is an hour long and their Item.releaseUsing does the
work. The spyglass takes the default too and is not release-ended at all: at
1200 ticks its countdown really can run out.
What the meal’s component holds, and what it does not
Consumable (DataComponents.CONSUMABLE) is the component the meal column
hangs off, and it holds the whole of the use half: Consumable.consumeSeconds,
with Consumable.consumeTicks derived from it; the ItemUseAnimation the
arm plays; the sound; the particles; and a list of ConsumeEffects to apply
at the end — ApplyStatusEffectsConsumeEffect,
RemoveStatusEffectsConsumeEffect, ClearAllStatusEffectsConsumeEffect,
TeleportRandomlyConsumeEffect and PlaySoundConsumeEffect, whose types are registered in
BuiltInRegistries.CONSUME_EFFECT_TYPE. What it does not hold is what the
food is worth, which is a second component and Part VIII’s (hunger and
experience).
Where the hour comes from
Duration is one base method and eight overrides. Item.getUseDuration’s base
body reads Consumable.consumeTicks off the stack if there is a Consumable
on it, and otherwise answers the hour for anything carrying
DataComponents.BLOCKS_ATTACKS or DataComponents.KINETIC_WEAPON — so a
shield and a spear get their long draw from the base method and no override of
their own. BowItem, CrossbowItem and TridentItem override it to return
the same 72000, the number Item.APPROXIMATELY_INFINITE_USE_DURATION names. At the other end EnderEyeItem returns
zero, which makes it the one item in the game whose use is instant by
declaration rather than by having no Consumable at all. The remaining four
overrides are ordinary finite numbers: the spyglass’s 1200, two hundred each
for BrushItem and BundleItem, and InstrumentItem’s, which is the goat
horn’s own sounded length in ticks.
Starting: the client finishes before it speaks
Minecraft.handleKeybinds sees the press and calls Minecraft.startUseItem,
which sets the four-tick Minecraft.rightClickDelay itself, refuses while
LocalPlayer.isHandsBusy, and walks both hands for a target. With nothing
under the crosshair it reaches MultiPlayerGameMode.useItem, which, once any
pending hotbar change has been sent, opens a prediction window
(MultiPlayerGameMode.startPrediction,
prediction and acks) and does the rest
inside it: it builds the ServerboundUseItemPacket — hand, sequence
number and both rotations — then consults the client’s own ItemCooldowns,
and runs ItemStack.use locally first only if the item is off cooldown.
The packet goes up either way; the cooldown suppresses the prediction, not the
report of it. The prediction is complete before the use packet leaves the client, for
the meal and the bow alike. Both answer InteractionResult.CONSUME, whose
InteractionResult.SwingSource.NONE is why neither swings the arm, although
FirstPersonHandsAndItems.itemUsed still runs — the small dip the item makes as
the use begins. An instant use, one whose ItemStack.getUseDuration is
zero, returns its outcome the other way instead, through
InteractionResult.Success.heldItemTransformedTo, which both game modes
unwrap and write back into the hand.
On the server, ServerGamePacketListenerImpl.handleUseItem acknowledges the
sequence number, snaps the player’s rotation to the one in the packet,
and calls ServerPlayerGameMode.useItem. That snap is what makes the
release strange later, because the release packet does no such thing. And
ServerPlayerGameMode.useItem ends on a deliberate omission: it normally
re-sends the player’s inventory with
AbstractContainerMenu.sendAllDataToRemote
(containers and
menus), but not when the use it
just ran started a multi-tick one. While you are eating or drawing, the
server declines to correct your inventory.
While it runs: the flag on the wire and the flag the client believes
LivingEntity.startUsingItem writes two bits of
LivingEntity.DATA_LIVING_ENTITY_FLAGS
(synched entity
data) — bit one for
using, bit two assigned the hand, so a main-hand use clears it — and
only on the server. LivingEntity.isUsingItem and
LivingEntity.getUsedItemHand read those bits, which is how every other
client knows your arm is up.
Your own client does not read them. LocalPlayer.isUsingItem overrides the
base and returns a private local flag set by LocalPlayer.startUsingItem;
LocalPlayer.getUsedItemHand likewise answers from a local field.
Reconciliation happens afterwards, in both directions:
LocalPlayer.onSyncedDataUpdated compares the arriving bits with the local
flag and will start a use the client never predicted, or stop one it did.
The base LivingEntity.onSyncedDataUpdated makes the matching repair on
every other entity’s copy — it adopts the held stack and re-derives
LivingEntity.useItemRemaining from ItemStack.getUseDuration, which is why
a remote player drawing a bow animates correctly although your client never
saw the press.
One abandonment rule is shared, and it lives a level above the countdown.
LivingEntity.tick calls the private LivingEntity.updatingUsingItem,
which compares the hand’s current stack with the remembered one using
ItemStack.isSameItem and calls LivingEntity.stopUsingItem if they
differ; only on a match does it call LivingEntity.updateUsingItem, the
countdown proper. The comparison is item identity, not components, so
swapping a bowl for a stew aborts the meal while a durability tick on the
bow does not abort the draw.
Every tick, and what the bow does instead
LivingEntity.updateUsingItem offers ItemStack.onUseTick the count
before it decrements, so on the server a thirty-two-tick meal is offered 32
down to 1 and never 0; the client, waiting for its byte, goes on to 0 and
below. For the meal that one call is the whole visible experience:
Consumable.shouldEmitParticlesAndSounds is true once more than
Consumable.CONSUME_EFFECTS_START_FRACTION of the duration has elapsed and
the remaining count is a multiple of Consumable.CONSUME_EFFECTS_INTERVAL,
and Consumable.emitParticlesAndSounds then spawns five item particles
through LivingEntity.spawnItemParticles — behind
Consumable.hasConsumeParticles, which drinks turn off — and plays the chew
sound, which they do not. Level.addParticle does nothing on the server, so
the crumbs are pure client simulation.
For the bow the call does nothing whatever: BowItem does not override
Item.onUseTick, and the base body is empty. Everything you see while
drawing is the renderer reading the same counter the logic is decrementing.
FirstPersonHandsAndItemsRenderer computes the draw curve for
ItemUseAnimation.BOW from a per-frame copy of
LivingEntity.getUseItemRemainingTicks, and the three-stage bow texture is
not code at all: items/bow.json is a condition on using_item wrapping
a range_dispatch on the use_duration property (UseDuration), scaled so
its thresholds are fractions of BowItem.MAX_DRAW_DURATION.
The crossbow is the exception that makes the rule legible. It does
override Item.onUseTick, and that body is entirely server-side: it plays
the start, middle and end sounds a CrossbowItem.ChargingSounds holds (Quick
Charge’s has no middle) at fixed fractions of
CrossbowItem.getChargeDuration and, on reaching one, writes
DataComponents.CHARGED_PROJECTILES onto the stack. Its client half is a
Render-thread computation — CrossbowPull and FirstPersonHandsAndItems both
call CrossbowItem.getChargeDuration, which calls
EnchantmentHelper.modifyCrossbowChargingTime
(enchantments
owns the two enchantment values a client is allowed to compute). An
enchantment hook, evaluated on the Render thread every frame, to pose an arm and
pick one of three textures.
Moving while you use
Neither path slows you through movement code — both read one component.
LocalPlayer.modifyInput scales the movement input by
LocalPlayer.itemUseSpeedMultiplier, which reads
UseEffects.speedMultiplier off the stack, unless the player is riding, and
LocalPlayer.isSlowDueToUsingItem blocks sprinting because
UseEffects.canSprint is false. The number is a multiplier and not a
subtraction: UseEffects.DEFAULT carries 0.2, so the input is scaled to a
fifth of what you pressed. That default sits in
DataComponents.COMMON_ITEM_COMPONENTS, so every item has one — and neither
cooked beef nor the bow overrides it, which is why drawing a bow slows you
by exactly as much as eating does, through exactly the same field.
Item.Properties.spear is the definition that overrides it outright, with a
UseEffects that permits sprinting, suppresses vibrations and multiplies
speed by one; the hits that use makes need no packet at all, coming from the
server’s use tick itself (the spear).
The component’s remaining field is not about movement at all, and it is the half
that runs on the server. UseEffects.interactVibrations is what
ItemStack.causeUseVibration consults before letting a use emit a game
event, so an item whose component clears the flag starts and finishes
silently as far as a sculk sensor is concerned. LivingEntity calls it at
both ends of a use — GameEvent.ITEM_INTERACT_START when the timer opens and
GameEvent.ITEM_INTERACT_FINISH when it closes — and FishingRodItem and
BoneMealItem call it for themselves, because neither ends through the use
timer (game events and
vibrations).
The two endings
There are two, and they are separate machines that share a last step. The first is the countdown reaching zero, which is the meal.
flowchart TD
T["LivingEntity.updateUsingItem, both sides, every tick"]
T --> A["ItemStack.onUseTick, with the count before the decrement"]
A --> B["decrement LivingEntity.useItemRemaining"]
B --> C{"reached zero"}
C -- no --> T
C -- yes --> D{"on the server"}
D -- "no, this is the client" --> W["keep counting into the negatives and wait"]:::client
D -- yes --> E{"ItemStack.useOnRelease"}
E -- "true, a crossbow" --> T
E -- "false, everything else" --> F["LivingEntity.completeUsingItem"]:::server
F --> G["ServerPlayer.completeUsingItem sends ClientboundEntityEventPacket 9 first"]:::server
G --> H["ItemStack.finishUsingItem, then LivingEntity.stopUsingItem"]:::server
G -. "the byte lands" .-> X["Player.handleEntityEvent replays LivingEntity.completeUsingItem"]:::client
W --> X
The client’s arm of the branch reaches nothing on its own. The dotted edge is the whole of what ends a meal on the client — a byte from the server, arriving at a branch that was otherwise waiting for ever.
The second is the key coming up, which is the bow, and nothing in it waits for a reply at all.
flowchart TD
R["the use key comes up, Minecraft.handleKeybinds"]:::client
R --> S["MultiPlayerGameMode.releaseUsingItem sends RELEASE_USE_ITEM"]:::client
S --> P["ServerGamePacketListenerImpl.handlePlayerAction, no ack, no sequence"]:::server
S --> Q2["LivingEntity.releaseUsingItem on the client's own copy"]:::client
P --> Q["LivingEntity.releaseUsingItem on the server's"]:::server
Q --> K["ItemStack.releaseUsing, then LivingEntity.stopUsingItem either way"]
Q2 --> K
K -- "Item.releaseUsing returned true" --> L["the shot, then the after-use side effects"]
K -- "returned false" --> M["the meal is simply abandoned"]
One method run twice, once per copy, and the split at the top is the
point — the client releases locally without waiting, and only the server’s
run reaches a ServerLevel and so an arrow.
The client’s branch has no exit. Nothing on the client ever reaches
LivingEntity.completeUsingItem from the countdown — it is called from
Player.handleEntityEvent when event 9 arrives, and nowhere else on that
side. The counter meanwhile keeps falling past zero and only the renderer
notices: FirstPersonHandsAndItemsRenderer draws a use pose solely while
LivingEntity.getUseItemRemainingTicks is positive, so the arm drops at
tick 32 whether or not the packet has landed.
And ItemStack.useOnRelease does not mean “ends on release”. It means
do not let the countdown finish this, and give it one more tick when the
key comes up: LivingEntity.releaseUsingItem calls
LivingEntity.updatingUsingItem again when it is true, so a crossbow gets a
final CrossbowItem.onUseTick in which it can still latch the charge. No
other item asks for that. Release is also not only a key-up: besides the
client’s and the server’s key-up paths, LivingEntity.releaseUsingItem is
called from five other places — once by LivingEntity.completeUsingItem
itself, when the stack turned out not to match the hand; once each by
CrossbowAttack and RangedCrossbowAttackGoal, which is how a piglin and a pillager finish loading; and twice by BrushItem.onUseTick, which ends its own use from inside
the tick.
The meal, tick by tick
The whole of it is one press, thirty-one ticks in which the meal sends its eater nothing, and one byte, and the three bands below are those three.
sequenceDiagram
participant LP as LocalPlayer
participant MPGM as MultiPlayer<br/>GameMode
participant Wire as the network
participant SGPL as ServerGamePacket<br/>ListenerImpl
participant SP as ServerPlayer
participant Cons as Consumable
rect rgba(0, 0, 0, 0.04)
Note over LP,Cons: tick 0, the press
MPGM->>Cons: startConsuming, reached from Minecraft.startUseItem through Item.use
Cons->>LP: startUsingItem, once canConsume has asked Player.canEat
MPGM->>Wire: ServerboundUseItemPacket, hand and sequence and both rotations
Wire->>SGPL: handleUseItem acks the sequence and snaps the rotation
SGPL->>SP: the same Item.use, remaining = 32, the two flag bits written
end
rect rgba(0, 0, 0, 0.04)
Note over LP,Cons: ticks 1 to 31, both sides
LP->>LP: ItemStack.onUseTick, particles and the chew sound once the delay is past
SP->>SP: the same call, particles discarded, sound broadcast to everyone else
end
rect rgba(0, 0, 0, 0.04)
Note over LP,Cons: tick 32, the count at zero, and only the server ending it
SP->>Wire: ClientboundEntityEventPacket, EntityEvent.USE_ITEM_COMPLETE
SP->>Cons: Consumable.onConsume, from ItemStack.finishUsingItem, FoodData.eat inside it
Wire->>LP: handleEntityEvent replays LivingEntity.completeUsingItem locally
SP->>Wire: ClientboundSetHealthPacket, same tick, overwrites the prediction
end
Note over Wire,SP: a later tick: AbstractContainerMenu.broadcastChanges corrects the count
Three bands, and the count reaches zero in only one of them. Watch the third — the client is told the meal is over by a single byte, and its own countdown had nothing to do with it.
The stack’s own count is the last thing to agree: the menu’s
AbstractContainerMenu.broadcastChanges corrects it a tick or more later,
which is a container’s business rather than the meal’s.
The replay is the interesting half. Consumable.onConsume runs on both
sides and three parts of it do not: the Stats.ITEM_USED award and
CriteriaTriggers.CONSUME_ITEM need a ServerPlayer, the ConsumeEffects
in Consumable.onConsumeEffects sit behind a server-side guard, and
GameEvent.EAT is a no-op because ClientLevel.gameEvent has an empty
body. Particles, sound, the ConsumableListener walk that finds
FoodProperties, and the ItemStack.consume shrink all run on both — and
every one of those client mutations is then overwritten. That is why a
chorus fruit’s teleport is never predicted while the hunger bar’s jump is
(hunger and
experience).
One meal, two exactly-once sound strategies. The chew sound goes through
Player.playSound, which on the server names the eater as the entity to exclude, so the
server broadcasts it to everyone else; on the eater’s client LocalPlayer.playSound plays it
locally without the test. The FoodProperties eat and burp sounds pass no exclusion at all —
and ClientLevel.playSeededSound plays a sound only when the excluded
entity is the local player — so those reach the eater as the server’s
broadcast alone.
The bow, tick by tick
The same lanes, the same opening, and then a release that nobody waits for.
sequenceDiagram
participant LP as LocalPlayer
participant MPGM as MultiPlayer<br/>GameMode
participant Wire as the network
participant SGPL as ServerGamePacket<br/>ListenerImpl
participant SP as ServerPlayer
rect rgba(0, 0, 0, 0.04)
Note over LP,SP: tick 0, the press
MPGM->>LP: startUsingItem, remaining = 72000, once BowItem.use has a projectile
MPGM->>Wire: ServerboundUseItemPacket, hand and sequence and both rotations
Wire->>SGPL: handleUseItem acks the sequence and snaps the rotation
SGPL->>SP: startUsingItem too, remaining = 72000, the two flag bits written
end
rect rgba(0, 0, 0, 0.04)
Note over LP,SP: every tick after that, both sides
LP->>LP: the count falls, onUseTick is empty, the model reads the use duration
SP->>SP: the count falls, and only the using-item trigger fires
end
rect rgba(0, 0, 0, 0.04)
Note over LP,SP: the tick the key comes up
MPGM->>Wire: ServerboundPlayerActionPacket, RELEASE_USE_ITEM, sequence zero
MPGM->>LP: releaseUsingItem, and BowItem.releaseUsing on the client's copy
LP->>LP: no ServerLevel, so a phantom stack, no ammo, no arrow, then stopUsingItem
Wire->>SGPL: handlePlayerAction, with whatever rotation the server last heard
SGPL->>SP: releaseUsingItem, the identical call on the server's copy
SP->>SP: BowItem.releaseUsing, then<br/>ProjectileWeaponItem.<br/>draw and shoot
SP->>Wire: Projectile.spawnProjectile, then the add-entity packet
SGPL-->>MPGM: nothing acknowledges the release itself
end
Note over Wire,SP: a later tick: the container sync corrects the arrows and the damage
The same method twice, once per copy — the two self-messages in the
third band are BowItem.releaseUsing on each machine, and only the lower one
has a ServerLevel to shoot into. The dashed line is the only reply, and it
is empty.
BowItem.releaseUsing runs on both sides and gets nowhere on one of them.
It measures the draw as BowItem.getUseDuration minus the remaining count,
puts it through BowItem.getPowerForTime — a curve on the draw time in
seconds, clamped at one after BowItem.MAX_DRAW_DURATION ticks — and
returns false below a tenth of full power, which is why a tap of the button
neither shoots nor costs durability. Above it, ProjectileWeaponItem.draw
decides how many arrows leave the string
(EnchantmentHelper.processProjectileCount) and
ProjectileWeaponItem.useAmmo decides whether an arrow is spent
(EnchantmentHelper.processAmmoUse). Both consult a ServerLevel and fall
back to one and zero otherwise, and ProjectileWeaponItem.shoot is itself
inside a ServerLevel test — so on the client the draw produces a single
phantom arrow marked DataComponents.INTANGIBLE_PROJECTILE, spends nothing
and shoots nothing.
On the server it is six enchantment hooks and one ordering worth
remembering. Two have already run inside ProjectileWeaponItem.draw; the
third, EnchantmentHelper.processProjectileSpread, fans a multishot
volley, and each arrow is aimed by BowItem.shootProjectile through
Projectile.shootFromRotation using the server’s rotation — which the
release packet never updated, so the shot goes where the last movement
packet said you were looking. Projectile.spawnProjectile aims, adds the
entity to the level, and only afterwards calls
Projectile.applyOnProjectileSpawned, which runs
EnchantmentHelper.onProjectileSpawned twice when ammo and weapon are
different items: once for the arrow’s stack and once for the bow’s. The fifth
is out of sight in the arrow itself — the AbstractArrow constructor asks
EnchantmentHelper.getPiercingCount off the weapon it was fired from
(enchantments).
ItemStack.hurtAndBreak takes the durability after each arrow, through the
sixth, EnchantmentHelper.processDurabilityChange, and the volley breaks off
if the bow dies mid-flight.
Only when Item.releaseUsing returns true does ItemStack.releaseUsing run
ItemStack.applyAfterUseComponentSideEffects — the same private step
ItemStack.finishUsingItem runs for the meal, converting
DataComponents.USE_REMAINDER into the empty bowl and starting
DataComponents.USE_COOLDOWN. (For an instant use it runs from
ItemStack.use instead, and only for a zero-duration success.) Cooldowns
are grouped rather than per-item: ItemCooldowns.getCooldownGroup returns
UseCooldown.cooldownGroup when the component names one and the item’s
Identifier otherwise, both sides own an ItemCooldowns — the client’s is
a real prediction, consulted before it will even predict a use — and
ServerItemCooldowns.onCooldownStarted mirrors the server’s as one
ClientboundCooldownPacket naming a group.
What the ending never carries
The release is the least-answered packet in the pipeline.
ServerGamePacketListenerImpl.handlePlayerAction treats
ServerboundPlayerActionPacket.Action.RELEASE_USE_ITEM in one line — call
LivingEntity.releaseUsingItem, return — with no ack, no sequence number
consumed, not even a spectator check. Everything the client learns about its
own shot arrives as ordinary world traffic. The arrow is the quick one: adding
it to the level starts its tracking inside the same call, so the spawn packet
leaves on that tick. The spent ammo and the bow’s damage wait for a container
slot update (containers and
menus) and the cleared
using-flag for entity data it has already acted on, both a tick or more
later. The shoot sound is broadcast with no
exclusion, so — by the same rule as the burp — the shooter hears the
server’s copy and never their own.
The completion is barely richer. There is no you ate this packet: event 9
tells the client to re-derive the outcome from components it already holds,
and ClientboundSetHealthPacket corrects the hunger it got wrong, the
container sync the count. The single
override of Item.finishUsingItem in the whole tree is
SpyglassItem.finishUsingItem, which plays a sound — the spyglass being the
one item whose completion is worth a sound of its own.
That override is reached only by the countdown running out, all 1200 ticks of
it. Letting go takes the other door: SpyglassItem also overrides
Item.releaseUsing — the fourth and last override of it in the tree, after
the bow, the crossbow and the trident — plays the same sound there through the same private SpyglassItem.stopUsing,
and returns true. So the spyglass gets its sound either way, through two
overrides that meet only in that helper. That is the last thing this page has to say about the
two endings: they are not one path with a switch on it. They are two.
For a 1.21-era reader. There is no bow-pull item property class left to hunt for. The old pulling / pull pair is now the shared
UseDurationrange-select property plus a using_item condition, both declared in the item’s JSON. The crossbow keeps a bespoke property,CrossbowPull, only because its denominator is enchantable. ItemInHandRenderer is gone;FirstPersonHandsAndItemsandFirstPersonHandsAndItemsRendererdo its work.
Where to look
LivingEntity is the whole machine, and four of its methods in this order are
the page: LivingEntity.startUsingItem, the private
LivingEntity.updatingUsingItem that decides whether to keep going,
LivingEntity.updateUsingItem that counts, and LivingEntity.releaseUsingItem
for the other ending. LivingEntity.completeUsingItem beside them is the one
the client never reaches from its own countdown.
For where a use begins, read down: Minecraft.startUseItem, then
MultiPlayerGameMode.useItem for the prediction window, then
ServerGamePacketListenerImpl.handleUseItem for the rotation snap, then
ServerPlayerGameMode.useItem for the inventory re-send it declines to do.
ItemStack.use is where both sides dispatch into the item.
Consumable is the meal in one class. BowItem is the bow, and
ProjectileWeaponItem above it holds the two methods that decide how many
arrows leave and whether any is spent; CrossbowItem is the one ranged
weapon that uses the tick hook and the one item that sets
ItemStack.useOnRelease. UseEffects shapes the use while it runs and UseCooldown outlives it, and FirstPersonHandsAndItems and
FirstPersonHandsAndItemsRenderer between them are everything you see.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Containers and menus
Verified against Minecraft 26.3 · Part VII · A player shift-clicks a stack out of a chest: one packet goes up, the server re-runs the same code against the real container, and nothing comes back down.
A stack of cobblestone sits in the top-left slot of a chest, and a
shift-click lands it in the hotbar before anything has answered. One
ServerboundContainerClickPacket leaves for the server; the server runs the
same method against the real ChestBlockEntity, gets its own answer,
compares the two — and if they agree, sends nothing at all. That is the
steady state: one packet up, zero packets down. There is no transaction
acknowledgement in this protocol; agreement is silence. What makes silence
safe is the part nobody expects. The click packet does not carry the stacks
the client thinks it produced; it carries each changed slot as an item, a count
and a CRC32C hash per component its patch adds, and the server writes those claims into its own per-slot record of what the
client believes before it compares any slot. The record is the server’s, and what a click is allowed to fill it with is a hash and never a stack.
The cast
| class | what it decides | thread |
|---|---|---|
Container | storage, and nothing about who is looking at it — Container.getItem, Container.setItem, Container.stillValid | wherever its owner runs |
AbstractContainerMenu | the slot list, the cursor, the state id, the click path, and two independent records of what has already been reported — one for the advancement listeners, one for the wire | Server and Render |
Slot | GUI policy — Slot.mayPlace, Slot.mayPickup, Slot.getMaxStackSize — and the guarded mutations a click goes through | Server and Render |
Inventory | the player’s own storage, present as slots in nearly every menu that opens — the lectern’s one book slot is the exception | Server and Render |
MenuType / MenuProvider | the registry entry with the screen-side constructor, and the server-side factory a block hands to ServerPlayer.openMenu | Render / Server |
ContainerSynchronizer | the channel from a menu’s diff to the connection, and the writer of every slot update a click causes but one — one per ServerPlayer, shared by every menu that player opens | Server |
RemoteSlot | what the server believes the client is holding in one slot, as either a stack or a hash | Server (RemoteSlot.PLACEHOLDER on the Render thread) |
HashedStack | the client’s claim about one slot after its own click | created on the client, matched on the server |
The chest you see is not the chest
The server’s ChestMenu is built from a MenuProvider that
ChestBlock.getMenuProvider produces and ChestBlock.useWithoutItem hands
to ServerPlayer.openMenu (block
interaction),
which is a fixed sequence: close whatever menu was already open, take the
next container id, ask the provider for a menu, send
ClientboundOpenScreenPacket, and then ServerPlayer.initMenu — which
attaches the listener and the synchronizer, and whose attaching of a
synchronizer is what sends the single ClientboundContainerSetContentPacket
carrying the contents. The screen arrives before the contents do, and both
arrive after the menu object exists on the server. Its Container is the real
ChestBlockEntity — or a
CompoundContainer over both halves, for a double chest, from a separate
anonymous provider that unpacks both halves’ loot tables itself. The client’s ChestMenu is built
from MenuType’s screen-side constructor by MenuScreens, and that
constructor makes a fresh SimpleContainer. The client’s chest contents
have no connection to the block entity beyond the packet stream. Its
AbstractContainerMenu.stillValid would be a lie for the same reason —
SimpleContainer.stillValid returns true unconditionally — but the question is
never put to it: every call site of AbstractContainerMenu.stillValid in the
game is on the server. A client never closes its own menu because it walked
away.
Three asymmetries between the two copies
The second asymmetry, after the fresh container above, is the synchronizer. Only
ServerPlayer.initMenu ever calls
AbstractContainerMenu.setSynchronizer, so a client menu has none, and
every one of its RemoteSlots stays RemoteSlot.PLACEHOLDER, whose
RemoteSlot.matches always answers true, so the wire half of
AbstractContainerMenu.broadcastChanges is inert on the client by
construction. The listener half is not: BeaconScreen, ItemCombinerScreen,
LecternScreen and the creative inventory all register a real
ContainerListener, which is how the anvil’s rename field repopulates itself
after a slot changes.
The last of the asymmetries is the one the other pages of this part lean
on. A block-anchored menu reaches the world through a ContainerLevelAccess,
and the client’s copy holds ContainerLevelAccess.NULL, whose
ContainerLevelAccess.evaluate runs nothing and returns an empty optional,
so its ContainerLevelAccess.execute runs nothing either. Anything a menu does
inside that call — matching a recipe, spending lapis, chipping an anvil — is therefore skipped wholesale on the client, whatever the body itself tests. What the client really runs in those cases
is whatever guard sits in front of the call, which is why a client copy of a
menu can still refuse a click it cannot afford.
Twenty-eight subclasses, one machine
There are twenty-eight AbstractContainerMenu subclasses in world/inventory,
and after this page’s chest, the crafting grid, the anvil and the enchanting
table the rest are the same machine with a different slot list, LoomMenu,
CartographyTableMenu, BeaconMenu, BrewingStandMenu, CrafterMenu,
LecternMenu, DispenserMenu, HopperMenu, ShulkerBoxMenu, MerchantMenu
and the two mount menus among them. A station menu is a Container — the block
entity’s, or a scratch one the menu builds for itself on each side — a list of
Slots carrying the GUI policy, and, for a furnace’s progress or an anvil’s
price, a data slot or a ContainerData. ItemCombinerMenuSlotDefinition is the
small vocabulary the anvil and the smithing table are laid out from; SlotRanges
beside it is not a menu’s at all, but the table of slot names commands and
predicates read.
Three smaller facts that will otherwise trip you
A menu with a null MenuType cannot be opened the ordinary way, because
ServerPlayer.openMenu asks for its type and AbstractContainerMenu.getType throws. That is why the player’s own
InventoryMenu is pinned to InventoryMenu.CONTAINER_ID, zero, and built
independently on both sides, and why the two AbstractMountInventoryMenu
subclasses — the horse’s and the nautilus’s, which pass a null type up to
AbstractContainerMenu explicitly — need a
ClientboundMountScreenOpenPacket of their own instead of
ClientboundOpenScreenPacket. A chest boat is not one of them: it opens an
ordinary ChestMenu through ServerPlayer.openMenu like any other
container. Every other menu takes an id from
ServerPlayer.nextContainerCounter, which cycles 1 to 100 and so never
collides with the player’s own zero.
Slot.mayPlace and Container.canPlaceItem are unrelated questions. The
first is GUI policy and defaults to true without consulting the container at
all; the second is what hopper automation reads. A player and a hopper can
have different rights over the same slot, and neither of them is asking the
other’s question.
Container.getMaxStackSize defaults to 99. The familiar 64 comes only
from the per-stack overload, which takes the minimum with the item’s own
maximum — so a player and an item can disagree about a slot’s capacity the
same way a player and a hopper disagree about its contents.
One shift-click, end to end
A three-row ChestMenu numbers slots 0–26 for the chest, 27–53 for the
player’s three main inventory rows and 54–62 for the hotbar, because
AbstractContainerMenu.addStandardInventorySlots adds the main rows before
the hotbar. Watch what the wire carries, and what it does not.
sequenceDiagram
participant MPGM as MultiPlayer<br/>GameMode
participant ChestM as ChestMenu
participant Wire as the network
participant SGPL as ServerGamePacket<br/>ListenerImpl
participant ACM as Abstract<br/>ContainerMenu
participant RemS as RemoteSlot
MPGM->>MPGM: copy every slot's stack, before touching anything
MPGM->>ChestM: clicked with QUICK_MOVE, on the client's own copy
ChestM->>ChestM: quickMoveStack, then moveItemStackTo backwards
MPGM->>Wire: ServerboundContainerClickPacket, state id plus changed slots as hashes
rect rgba(0, 0, 0, 0.04)
Note over MPGM,RemS: the server tick that drains the packet
Wire->>SGPL: handleContainerClick
SGPL->>ACM: suppressRemoteUpdates, then the same clicked on the real chest
SGPL->>ACM: setRemoteSlotUnsafe per claimed hash, then setRemoteCarried
ACM->>RemS: receive, dropping any concrete stack it held
SGPL->>ACM: resumeRemoteUpdates, then broadcastChanges
ACM->>RemS: matches
RemS->>RemS: the hash agrees, so it becomes a copy of the server's stack
end
Note over Wire,RemS: agreement is silence, and AbstractContainerMenu.synchronizeSlotToRemote sends nothing
One shift-click as one packet up and nothing down. Read the diagonal: the client runs the click, the server re-runs the identical call on the real chest, and the comparison happens only after both — the note at the foot is where a reply would have been.
The press. AbstractContainerScreen.mouseClicked resolves the hovered
slot, sees an empty AbstractContainerMenu.getCarried and a held shift, and
chooses ContainerInput.QUICK_MOVE on the press. Two near neighbours are
release-path instead, from AbstractContainerScreen.mouseReleased: a
shift-click with a non-empty cursor, and the shift-double-click sweep,
which issues a separate ContainerInput.QUICK_MOVE — and a separate packet
— for every matching slot in the same container.
The snapshot, then the prediction.
MultiPlayerGameMode.handleContainerInput copies every slot’s stack before
touching anything, because that copy is what the packet’s diff is built
against. Then it runs AbstractContainerMenu.clicked on the client’s own
menu. The ContainerInput.QUICK_MOVE branch loops
AbstractContainerMenu.quickMoveStack while the clicked slot keeps yielding
the same item, and it runs only for button 0 or 1, a non-negative slot index
and a slot that Slot.mayPickup allows.
The move, and its two passes. ChestMenu.quickMoveStack sees the index
is inside the chest and calls AbstractContainerMenu.moveItemStackTo over
the player’s range with the backwards flag, which scans from the last slot
down — the hotbar first, from the right. That method makes two passes.
The first runs only if the stack is stackable at all, and merges into every
existing compatible stack across the whole range
(ItemStack.isSameItemSameComponents, topped up to Slot.getMaxStackSize).
The second runs only if something is left, finds the first empty slot that
Slot.mayPlace accepts, places what that slot’s cap allows and breaks.
One empty slot per call — which is why the caller loops. Note which pass
consults policy: the merge pass never asks Slot.mayPlace, so a slot
that would refuse the item on placement can still be topped up by a
shift-click when it already holds a matching stack. Note also what
ChestMenu.quickMoveStack never calls: Slot.onTake.
InventoryMenu.quickMoveStack does. Shift-clicking out of a chest and out
of the crafting result are structurally different operations.
The packet. The client diffs the post-click slots against its snapshot,
hashes each changed stack with HashedStack.create, and sends
ServerboundContainerClickPacket with the container id, the client’s
last-known state id, the slot, the button, the ContainerInput, up to
128 changed slots as hashes, and the cursor as one more.
The real move. The server takes the ladder below, then — with
AbstractContainerMenu.suppressRemoteUpdates held — runs the identical
AbstractContainerMenu.clicked path against the real ChestBlockEntity and
the real Inventory. Slot.setChanged reaches BlockEntity.setChanged,
which calls Level.blockEntityChanged to mark the chunk and
Level.updateNeighbourForOutputSignal to re-derive the comparator output
(block entities, and
diodes and observers
for what a comparator does with it). The whole click is wrapped
in a try/catch that builds a crash report category naming the menu class,
the slot and the button.
Installing the claim, then comparing.
AbstractContainerMenu.setRemoteSlotUnsafe writes each hash the client sent
into the matching RemoteSlot through RemoteSlot.receive, and
RemoteSlot.Synchronized discards any concrete stack it was holding and
keeps the hash alone. An out-of-range
index is logged at debug and ignored rather than rejected. Then
AbstractContainerMenu.setRemoteCarried, then
AbstractContainerMenu.resumeRemoteUpdates, then
AbstractContainerMenu.broadcastChanges, which makes one pass over the
slots offering each to AbstractContainerMenu.triggerSlotListeners for
advancements and to AbstractContainerMenu.synchronizeSlotToRemote for the
wire, then the cursor, then the data slots. Where the hash agrees,
RemoteSlot.Synchronized promotes it to a concrete copy of the server’s
own stack and nothing is sent; where it disagrees, that one slot gets a
ClientboundContainerSetSlotPacket of its own with a fresh state id.
The advancement channel sees one state per click, not one per slot touched:
AbstractContainerMenu.triggerSlotListeners runs only from
AbstractContainerMenu.broadcastChanges and
AbstractContainerMenu.broadcastFullState — and for a chest nothing calls
back into the menu mid-click to reach either, a bundle click aside. That is a
fact about the chest, not about menus: several menus call
AbstractContainerMenu.slotsChanged from Container.setChanged or
Slot.setChanged (the anvil’s and the smithing table’s ItemCombinerMenu
containers, the crafter’s CrafterSlot, the grindstone’s, the lectern’s), a bundle click calls it on whatever menu is open,
and the base AbstractContainerMenu.slotsChanged is a bare
AbstractContainerMenu.broadcastChanges, which the menus that override it
reach only if they call up to it. The click’s
own broadcast runs
after AbstractContainerMenu.resumeRemoteUpdates, so suppression is not
in force by then, and ServerPlayer’s ContainerListener filters to slots
that are not a ResultSlot and whose container is the player’s own
Inventory before firing CriteriaTriggers.INVENTORY_CHANGED anyway.
The ladder the server climbs before it believes you
ServerGamePacketListenerImpl.handleContainerClick is four tests and a
fork, and the interesting thing about it is how much of it ends in nothing
sent rather than a correction. The packet reaches the handler on the server
thread through PacketUtils.ensureRunningOnSameThread, and then:
| the test | what a failure does |
|---|---|
does AbstractContainerMenu.containerId match the open menu | dropped in total silence — nothing logged, nothing sent |
| is the player a spectator, or dead or dying | AbstractContainerMenu.sendAllDataToRemote, a full resync, and the click never runs |
AbstractContainerMenu.stillValid | logged at debug, nothing sent, and the menu is not closed here |
AbstractContainerMenu.isValidSlotIndex | logged at debug, nothing sent, nothing corrected |
Past all four the fork begins, and its order is the load-bearing part.
flowchart TD
ST["compare the packet's state id with the menu's, before anything is applied"]:::server
ST --> AP["AbstractContainerMenu.suppressRemoteUpdates, the click, the hashes, resume"]:::server
AP --> Q{"was that state id stale?"}
Q -->|"stale"| FULL["AbstractContainerMenu.broadcastFullState: one set-content packet, fresh state id"]:::server
Q -->|"current"| BC["AbstractContainerMenu.broadcastChanges: every slot against its RemoteSlot"]:::server
The compare happens above the apply and the branch below it. Read the two edges out of the diamond as the whole difference a stale state id makes — not whether the click runs, only how its result is published.
The state id is compared before the click is applied and acted on after, so a click that quotes a stale id still runs, and still runs first. Its result is simply published wholesale instead of diffed.
AbstractContainerMenu.isValidSlotIndex deserves suspicion. It accepts −1,
accepts AbstractContainerMenu.SLOT_CLICKED_OUTSIDE, and otherwise only
asks whether the index is below the slot count — so every negative index
passes it. Each branch of the click that needs a lower bound therefore
tests for one itself. The two that do not, ContainerInput.SWAP and the
painting phase of ContainerInput.QUICK_CRAFT, index the list directly, and
the click’s own
try/catch turns the failure into a ReportedException rather than swallowing
it: what swallows it is PacketProcessor, which logs a game-listener error and
carries on. An out-of-range click is therefore neither corrected nor fatal, but it is loudly
logged, it leaves the menu’s remote updates suppressed until a later click
succeeds (the resume the handler would have run next never runs), and it closes
nothing:
ServerGamePacketListenerImpl.handleContainerClose for its part validates
nothing at all, not even the container id, and goes straight to
ServerPlayer.doCloseContainer.
Why hashes, and why only in one direction
HashedStack.create produces either HashedStack.EMPTY or a
HashedStack.ActualItem of item holder, count and a HashedPatchMap — one
CRC32C integer per added component plus the plain set of removed ones.
Each integer comes from running the component’s own codec into
HashOps.CRC32C_INSTANCE, a DynamicOps whose output is the hash
(codecs, NBT and
JSON owns
that mechanism). Nothing is serialised on the way and there is no intermediate
byte form. HashedPatchMap.matches then checks the removed set, the added
count, and each component’s hash in turn.
Two things follow. The client is asserting a belief, not authoring state —
a hashed component cannot be turned back into its value, so a client that lies
here can only fail to match — and each claimed slot costs an integer per component
rather than a re-encoded DataComponentPatch. The asymmetry is exact: only the client ever calls
HashedStack.create, and only the server ever calls HashedStack.matches.
Hashing is not free-standing on the server either: ServerPlayer’s
ContainerSynchronizer carries a 256-entry component-hash cache shared by
every menu that player opens, and ContainerSynchronizer.createSlot hands
each new RemoteSlot.Synchronized a HashedPatchMap.HashGenerator backed
by it.
The other channel: DataSlot, and why it is never silent
One channel is deliberately outside all of this. DataSlot and
ContainerData — furnace progress, enchanting cost, lectern page — travel
as ClientboundContainerSetDataPacket, whose id and value are written as
shorts, and they carry two independent baselines:
DataSlot.checkAndClearUpdateFlag for the listeners and
AbstractContainerMenu.remoteDataSlots for the network, compared
separately. A slot is a DataSlot.shared view onto an array the menu already keeps, a
DataSlot.standalone int the menu owns, or one entry of a ContainerData
through DataSlot.forContainer, and each one that differs costs its own packet. The network comparison is a plain integer test, so a furnace’s
progress bar is not covered by the hash-agreement silence and is re-sent
every time it changes.
The state id, and the three places it moves
AbstractContainerMenu.incrementStateId is a wrapping 15-bit counter — it
masks to 32767 — and it answers exactly one question: has the client
applied every slot correction I have sent? It has three call sites in the
whole game. Two are inside ServerPlayer’s synchronizer, behind
ContainerSynchronizer.sendInitialData and
ContainerSynchronizer.sendSlotChange;
ContainerSynchronizer.sendCarriedChange and
ContainerSynchronizer.sendDataChange do not bump it — and
ClientboundSetCursorItemPacket carries no id whatever, while
ClientboundContainerSetDataPacket carries the container’s but never a state
id. The third is CraftingMenu.slotChangedCraftingGrid, which bumps the id
on its way past while writing the crafting result’s packet by hand — the one write on the click path that goes around the synchronizer, and the
exception the cast row promised. The client never generates one: AbstractContainerMenu.setItem and
AbstractContainerMenu.initializeContents simply store whatever arrived and
quote it back on the next click. A click quoting a stale id means
corrections are still in flight, and the server stops diffing and resends
everything.
Where in the tick a broadcast happens
Not one place, and the difference is observable. Packets are drained
before any level ticks (the server tick), so
a click and any correction it produces both happen at the top of the tick
and reach the client the same tick. ServerPlayer.tick then calls
AbstractContainerMenu.broadcastChanges again from the level’s entity
phase, which runs before the block-entity phase
(the level tick). And ServerPlayer.doTick
— driven by the connection after every level has finished — repeats the
AbstractContainerMenu.stillValid distance test and broadcasts nothing of its
own; what does broadcast inside it is a pickup, because ServerPlayer.take,
which the player’s own tick reaches for every item, orb or arrow it collects,
calls AbstractContainerMenu.broadcastChanges.
The drain answers more than slot clicks. A menu button click — the
lectern’s page turn, the enchanting table’s offer, the loom’s pattern, the
stonecutter’s choice — is answered inside its own handler the same way:
ServerGamePacketListenerImpl.handleContainerButtonClick calls
AbstractContainerMenu.broadcastChanges directly, the moment
AbstractContainerMenu.clickMenuButton accepts, so the correction leaves on the
same packet drain that brought the click; pick-block and the creative slot
broadcast inside their handlers too.
Back in the tick, the distance test happens twice and only the first brings a broadcast
of its own. A hopper that pushes an item into a chest whose menu is open
therefore runs in the block-entity phase, after that tick’s regular broadcast, and nothing calls back into the menu to say so —
SimpleContainer.setChanged is empty, BlockEntity.setChanged marks the chunk
and re-derives the comparator output and stops there,
Inventory.setChanged only bumps a counter. You see the hopper’s item one tick
late, unless a pickup in the player’s own tick broadcasts first.
What the client does with a correction, and what closing rescues
Almost nothing, is the answer to the first. ClientPacketListener.handleContainerSetSlot
checks the container id, writes the stack into that one slot and stores the
state id that came with it. There is no rollback and nothing to roll back:
the client’s menu is the only copy of its own prediction, so a correction
overwrites it and the stack snaps. Nor, after a click the server ran to its end, is a mispredicted slot ever left standing — the server compares every slot against its record on every
broadcast, not only the ones the click claimed, so a wrong guess is corrected
on that tick or on the next one the menu is broadcast in.
Closing has its own surprise. The cursor belongs to the menu, not the
player, so closing one would destroy it: AbstractContainerMenu.removed
rescues it explicitly, dropping it in the world if the player has been removed for any reason but a
change of dimension, or has disconnected, and calling Inventory.placeItemBackInInventory
otherwise, the whole method gated on being a ServerPlayer, which is what
makes that safe. The rescue sends one ClientboundSetPlayerInventoryPacket
per slot it fills, and runs before AbstractContainerMenu.transferState,
which copies both the listener baseline and the remote beliefs across every
container-and-slot pair the closing menu and InventoryMenu share. For a
chest that is the 36 main and hotbar slots — not armour, not the offhand, not the 2×2 grid, not the crafting result — so changes to any of those are re-sent, and so is every slot the rescue has just filled, which it wrote before
the beliefs were copied.
The seven click kinds
ContainerInput has seven values, and the button number means something different in nearly every one. ClickAction — ClickAction.PRIMARY and
ClickAction.SECONDARY — is not on the wire; it is derived inside
AbstractContainerMenu.doClick on both sides and handed to the item
override hooks ItemStack.overrideStackedOnOther and
ItemStack.overrideOtherStackedOnMe, which is how a BundleItem
intercepts a click before ordinary slot logic runs.
| value | the gesture | what the button number means | packets per gesture |
|---|---|---|---|
ContainerInput.PICKUP | the ordinary click | 0 left, 1 right — and on AbstractContainerMenu.SLOT_CLICKED_OUTSIDE, drop all or drop one | 1 |
ContainerInput.QUICK_MOVE | shift-click | 0 or 1, the same in a real slot — and outside the window, the drop-all and drop-one of ContainerInput.PICKUP | 1, or one per matching slot for the shift-double-click sweep |
ContainerInput.SWAP | a hotbar key, or Inventory.SLOT_OFFHAND for the offhand key | the destination index: 0–8, or 40 | 1 |
ContainerInput.CLONE | creative middle-click | ignored, but the player must pass Player.hasInfiniteMaterials | 1 |
ContainerInput.THROW | Q over a slot, cursor empty — and also a click outside the window with an empty cursor, which the server’s branch then ignores because it demands a non-negative index | 0 drops one, 1 (control-Q) drops the stack and keeps going while the slot yields the same item | 1 |
ContainerInput.QUICK_CRAFT | drag-painting | a packed mask that AbstractContainerMenu.getQuickcraftHeader and AbstractContainerMenu.getQuickcraftType split into a phase (start, continue, end) and a mode (even split, one each, clone) | painted slots plus two — AbstractContainerScreen.quickCraftToSlots sends a start, one continue per slot, and an end |
ContainerInput.PICKUP_ALL | double-click to collect | 0 scans forwards, 1 backwards, over two passes that skip already-full stacks first | 1 |
An unknown id on the wire is not rejected. ContainerInput’s id mapper is
built with a zero-out-of-bounds strategy, so a malformed click decodes as
ContainerInput.PICKUP.
Two paths that are not this protocol at all
Creative mode is a parallel protocol, not a variation
CreativeModeInventoryScreen overrides
AbstractContainerScreen.slotClicked and drives a menu of its own, and its
writes go up as ServerboundSetCreativeModeSlotPacket. That is the only
serverbound packet in the game that carries an ItemStack at all, and the
server takes it verbatim: ServerGamePacketListenerImpl.handleSetCreativeModeSlot
puts the client’s stack into the slot through Slot.setByPlayer, behind only
a Player.hasInfiniteMaterials check, a feature-flag check, a slot range of
1 to 45 and a count cap — then writes the same stack into the server’s record
of the client’s belief with AbstractContainerMenu.setRemoteSlot, to suppress
the echo. (Other packets let a client author text the server keeps — a
rename, a sign, a jigsaw block — but no other lets it author a stack.) A negative
slot number means drop it in the world instead, and that branch is the one
place here with a rate limiter on it.
The crafting result is a second, unsuppressed channel
The result slot’s
packet is written by hand and goes straight down the connection, bumping the
state id on its way past and bypassing ContainerSynchronizer entirely
(recipes). What matters here is that it
fires during a click and is therefore the one path
AbstractContainerMenu.suppressRemoteUpdates does not cover: that flag guards
AbstractContainerMenu.synchronizeSlotToRemote and its two siblings, and
nothing else.
Everything else a menu puts on the wire is bookkeeping around those two —
opening and closing, the full resync, the button clicks, the crafter toggle,
the bundle selection, the hotbar key — and the whole set is one table in
the packet catalogue. Only one of them surprises: ServerboundSetCarriedItemPacket is the hotbar
selection, which despite the name has nothing to do with the cursor; the
packet the server sends for the same selection is ClientboundSetHeldSlotPacket.
Two things about a menu’s opening are worth keeping here. A structure chest
fills itself on the first open, and that is the roll rather than the sync
(loot tables). And
none of the click protocol is data-driven at all: BuiltInRegistries.MENU
supplies the type and nothing else, which is why nothing on this page has a
JSON file behind it.
Where to look
Follow the click in the order the two machines run it.
MultiPlayerGameMode.handleContainerInput is the snapshot and the
prediction; AbstractContainerMenu.doClick is the branch table both sides
share, with AbstractContainerMenu.moveItemStackTo under the shift-click.
ServerGamePacketListenerImpl.handleContainerClick is the ladder, and
AbstractContainerMenu.broadcastChanges is where the silence is decided.
For the belief mechanism, read RemoteSlot and then HashedStack: one is
the server’s record, the other is what a click is allowed to put in it.
HashOps under them is the trick that makes a hash out of a codec with no
bytes in between.
For the object model, Container and Slot are the two halves nobody
expects to be separate, and ChestMenu beside InventoryMenu is the
shortest way to see what a menu subclass adds. MenuType and
MenuProvider are the two ends of opening one, and MenuScreens is where
the client’s copy is built from a constructor the server never runs.
Two doors lead out of this protocol rather than through it:
CraftingMenu.slotChangedCraftingGrid writes the result slot by hand, and
ServerGamePacketListenerImpl.handleSetCreativeModeSlot is the one place a
client’s stack is taken at its word.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Recipes
Verified against Minecraft 26.3 · Part VII · Eight planks go around the empty centre of a crafting table, a chest appears in the result slot, and the client is never told which recipe it was.
You lay eight planks around the empty middle square of a crafting table and a
chest appears in the slot on the right. What the server did was take a trimmed
copy of the grid, scan every crafting recipe it has loaded in alphabetical
order until one matched, call a Recipe.assemble that ignored your planks
entirely, and push the answer down the wire in a packet written by hand,
outside the menu’s ordinary bookkeeping. What it never did was tell you which
recipe matched — and it could not have, because no Recipe ever crosses the
wire. Every RecipeSerializer is a record of a MapCodec and a
StreamCodec, and no game packet uses the stream half: the only reference to
Recipe.STREAM_CODEC anywhere in the tree is inside RecipeHolder.STREAM_CODEC,
which has no call sites at all. For the recipes it has unlocked the client
holds the whole contents — pattern, dimensions, ingredients, result — as a
RecipeDisplay. What it is denied is the identity, every recipe it has not
unlocked, and any authority whatever over the outcome.
The cast
| class | what it decides | thread |
|---|---|---|
Recipe | ten methods and no result getter — a result leaves through Recipe.assemble, Recipe.display, or a stonecutter’s StonecutterRecipe.resultDisplay | data, and a Recipe object never leaves the server |
RecipeManager | the loaded set, four indexes derived from it, and the server’s RecipeAccess | the worker pool for its constructor; the Server thread after, the first finalize on whichever thread builds the server |
RecipeMap | the immutable store, holding exactly two indexes: RecipeMap.byType and RecipeMap.byKey | built on the worker pool, swapped in and read on the Server thread |
Ingredient | whether one stack satisfies one slot — a HolderSet of items wearing a predicate face | both sides |
CraftingInput | the trimmed grid a crafting recipe is matched against, and the presence index a shapeless one is matched with | Server, and a result slot’s ResultSlot.onTake builds one on the Render thread too |
ResultSlot | the order of the endgame: award first, then look the recipe up again, then decrement | Server, mirrored on the Render thread’s menu |
ServerRecipeBook | which recipes this player has unlocked, and which of them still glow | Server |
ClientRecipeContainer | everything the client knows about recipes as recipes: nine RecipePropertySets and the stonecutter’s input set | Render, rebuilt wholesale from each packet |
Loading: one scan, one swap, and four indexes built later
RecipeManager is not a reload listener. RegistryDataLoader reads the recipe
files into a reloadable registry, Registries.RECIPE, and a fresh
RecipeManager builds its RecipeMap from that registry in its constructor,
both on the worker pool; on a reload the Server thread then swaps the new manager in.
That is the two-phase shape of
the resource system without a listener — and
the second phase does something unusual.
flowchart TD
W["RegistryDataLoader reads data/ns/recipe into Registries.RECIPE"]:::worker
W --> M["RecipeManager constructor builds its RecipeMap"]:::worker
M --> A["MinecraftServer.reloadResources swaps in the new manager"]:::server
A -. "the four indexes below are empty, not stale" .-> F
F["RecipeManager.finalizeRecipeLoading, called by MinecraftServer"]:::server
F --> P1["nine RecipePropertySet indexes"]:::server
F --> P2["SelectableRecipe.SingleInputSet"]:::server
F --> P3["RecipeManager.allDisplays, indexed by RecipeDisplayId"]:::server
P3 --> P4["RecipeManager.recipeToDisplay, key to displays"]:::server
The reload’s two phases — the worker pool’s two boxes above, the Server thread’s below — and the gap between them. The dotted edge is the one the section is about: the swap is not what builds the four indexes, and between those two boxes they hold nothing.
Each index applies its own feature-flag filter as it is built: an ingredient
is dropped from a RecipePropertySet unless every item in it is enabled, the
stonecutter’s set keeps an entry only if input and result display are both
enabled, and a display is kept only if its result and its crafting station
are. Three tests of three different things, all against one FeatureFlagSet, the
level’s, and the same filter ItemStack.isItemEnabled applies to an assembled
stack two sections below.
Three things in that picture are worth saying out loud.
The scan is sorted, and the sort is the whole ordering story.
ResourceManagerRegistryLoadTask.load registers the recipes sorted by their
files’ Identifiers, RecipeMap keeps that order, and Identifier.compareTo
compares the path first and the namespace only to break a tie. So
foo:acacia_boat sorts ahead of minecraft:zzz, matching is deterministic
across restarts, and the same order fixes the numbering of every display id
below.
The indexes are not built with the map.
RecipeManager.finalizeRecipeLoading has exactly two call sites, both of them in MinecraftServer, and neither is on the worker pool. Between the
swap and that call the four derived indexes are empty rather than stale: a
reload builds a fresh RecipeManager, and its constructor builds the
RecipeMap but leaves all four at their empty values, so the recipe book, the
property sets and the stonecutter index describe nothing at all rather than
describing the pack you just replaced. Nothing can catch the game in that
state — the swap and the call are five statements apart in one lambda on the Server thread — which is the only reason the gap is allowed to exist.
The last two indexes are a pair: RecipeManager.allDisplays is the flat
list, and RecipeManager.recipeToDisplay maps a recipe key to the entries it
produced in it.
A RecipeDisplayId is a list index, not an identifier. It is a record
wrapping a single int, and the int is the position the entry took in the flat
display list. Reload an unchanged pack and every id comes back identical; add
one recipe near the front and everything after it shifts. The server does not
try to work out which ids moved — ServerRecipeBook.sendInitialRecipeBook
re-sends the player’s whole book with the replace flag set. Nor is the list one
entry per recipe: Recipe.display returns a list, and TransmuteRecipe
returns one display per legal material count — up to
TransmuteRecipe.MAX_MATERIAL_COUNT, eight — so a single recipe can occupy
eight consecutive ids.
The same walk decides what a recipe is, and it forgives more than you would
expect. A non-special recipe whose PlacementInfo.isImpossibleToPlace — the cause being an ingredient tag that
resolved to nothing, which
Ingredient.CODEC cannot reject because only a literal empty list is illegal
— is logged as unplaceable and then kept. It stays in RecipeMap, where its empty ingredient matches no stack and so no
manual craft, and it still gets a RecipeDisplayEntry, one whose ingredient list
is present but empty; and RecipeDisplayEntry.canCraft on an empty ingredient
list is trivially true, so the book paints as craftable a recipe nobody can make. Only gate six of the auto-fill, below, stops the click.
It is not only shaped and shapeless
RecipeSerializers registers twenty-two serializers and fourteen of them are
crafting-table recipes. Nine of those fourteen are CustomRecipes — matching and assembly written in
Java, though eight of the nine read their ingredients and base result from JSON. CustomRecipe hard-codes Recipe.isSpecial true, Recipe.group
empty and PlacementInfo.NOT_PLACEABLE, and not one of the nine overrides
Recipe.display, so a special recipe contributes nothing to the display list in
the first place. Eight of the nine register under an id beginning crafting_special_, which is
where the name comes from; the ninth, DecoratedPotRecipe, registers as
crafting_decorated_pot and is a CustomRecipe all the same. The nine are the crafting recipes the book never shows: FireworkStarRecipe,
FireworkStarFadeRecipe, FireworkRocketRecipe, BookCloningRecipe,
BannerDuplicateRecipe, ShieldDecorationRecipe, MapExtendingRecipe,
DecoratedPotRecipe and RepairItemRecipe, which is the one this part comes
back for: it fuses two items of one kind that have durability and carries every curse from
both inputs onto the result (enchanting). Of
the five crafting-table serializers that remain, ShapedRecipe and
ShapelessRecipe are the pair everyone knows, and DyeRecipe, ImbueRecipe and TransmuteRecipe are genuine
NormalCraftingRecipes — data-driven, with hand-written matching, hand-built
placement info, and the exotic SlotDisplay variants to draw themselves with
(SlotDisplay.OnlyWithComponent and SlotDisplay.DyedSlotDemo for the dye
recipe, SlotDisplay.WithAnyPotion for the imbue). So special and neither shaped nor
shapeless are not the same set, and the difference matters, because it is
special that the book cannot show.
Eight planks: the trace
Putting the last plank into a crafting grid is two traces, the second on a later click, and only the second one reaches the recipe book.
sequenceDiagram
participant CraftM as CraftingMenu
participant TCC as TransientCrafting<br/>Container
participant RM as RecipeManager
participant ResultC as ResultContainer
participant Wire as the network
rect rgba(0, 0, 0, 0.04)
Note over CraftM,Wire: the tick the eighth plank lands
CraftM->>TCC: asCraftInput, trimming the empty rows and columns
CraftM->>RM: getRecipeFor CRAFTING, this input, this level, no hint
RM-->>CraftM: the first RecipeHolder that matches, in registry order, or nothing
CraftM->>ResultC: setRecipeUsed, refused under LIMITED_CRAFTING
CraftM->>ResultC: setItem 0, the assembled stack
CraftM->>Wire: ClientboundContainerSetSlotPacket, bumping the state id
end
The result slot is filled and announced in the tick the grid changed, and nothing has been taken yet. The packet at the foot is written by hand rather than left to the menu’s own diffing pass.
Nothing above happens on the client: the client’s CraftingMenu is handed
ContainerLevelAccess.NULL and matches nothing. What arrives is the last
arrow. The second half of the trace is a later click, and only then does a recipe reach
the book.
sequenceDiagram
participant ResultS as ResultSlot
participant ResultC as ResultContainer
participant SRB as ServerRecipeBook
participant RM as RecipeManager
participant TCC as TransientCrafting<br/>Container
participant Wire as the network
rect rgba(0, 0, 0, 0.04)
Note over ResultS,Wire: a later click, the player takes the result
ResultS->>ResultS: checkTakeAchievements runs before a cell is emptied
ResultS->>ResultC: awardUsedRecipes, which then nulls the stored holder
ResultC->>SRB: addRecipes, through ServerPlayer.awardRecipes
SRB->>Wire: ClientboundRecipeBookAddPacket
ResultS->>RM: getRecipeFor again, holder or no holder
ResultS->>TCC: Container.removeItem, one cell at a time, each followed by its remainder
Note over ResultS,TCC: every removal re-enters CraftingMenu.slotsChanged
end
Taking the stack is where the recipe reaches the book, and where the grid is emptied a cell at a time — each removal starting the first figure again.
The grid changes. Writing a plank into the menu’s
TransientCraftingContainer calls AbstractContainerMenu.slotsChanged, and both
crafting overrides of it end in the same static,
CraftingMenu.slotChangedCraftingGrid. They reach it differently:
CraftingMenu.slotsChanged goes through ContainerLevelAccess.execute, while
InventoryMenu.slotsChanged calls the static directly, gated only on having a
ServerLevel. The two-argument CraftingMenu constructor — the client’s — is
handed ContainerLevelAccess.NULL, which runs nothing at all (containers and
menus), so the
client never matches anything.
Trimming. Every recipe kind is matched against a RecipeInput, and there
are exactly four implementations of it in the game: CraftingInput for a
grid, SingleRecipeInput for the one-slot stations — the four AbstractCookingRecipe
kinds and the stonecutter’s, all of them SingleItemRecipes —
SmithingRecipeInput for the smithing table’s three, and BrewingInput for a
brewing stand’s bottle and reagent.
CraftingContainer.asPositionedCraftInput produces a
CraftingInput with the empty border rows and columns removed and the offset
beside it, which is why a shaped recipe works anywhere in the grid;
CraftingContainer.asCraftInput is the same call with the offset thrown away,
and it is what matching uses. Its constructor also fills a
StackedItemContents, and does it twice unusually: it accounts every stack as
one item, and it calls StackedItemContents.accountStack directly rather
than the filtered StackedItemContents.accountSimpleStack the recipe book
uses. Both choices point the same way — this index is a presence set for
shapeless matching, not the arithmetic the auto-fill does, and it deliberately
does not apply the auto-fill’s filter. What that filter is, and who it catches
out, is the last section of this page.
Matching is a linear scan. RecipeMap.getRecipesFor exits immediately on an
empty input, then streams the recipes of that RecipeType and filters them
through Recipe.matches, and RecipeManager.getRecipeFor takes the first.
ShapedRecipePattern.matches compares the ingredient count, then demands exactly
matching trimmed dimensions, then tries the mirrored layout before the
straight one — unless the pattern is symmetrical, which Util.isSymmetrical
settles once in the constructor. The chest’s ring of planks is symmetrical, so
only the straight pass ever runs, and a one-column pattern is always symmetrical
too. ShapelessRecipe.matches rejects on count, short-circuits the single-slot
case, and otherwise hands the presence index to StackedItemContents.canCraft,
which is a thin wrapper: the bipartite search itself lives in the generic
StackedContents that StackedItemContents holds and delegates to.
Three accelerations sit on top of that scan, and each belongs to a different
caller. RecipeManager.getRecipeFor takes an optional hint and tests it
before scanning; the auto-fill supplies one through
CraftingMenu.finishPlacingRecipe, so the recipe it has just laid out is
the first thing re-matched — and the base
AbstractCraftingMenu.finishPlacingRecipe is a no-op, so the player’s own
2×2 grid never gets one. RecipeManager.CachedCheck remembers the last
successful key and re-hints with it — AbstractFurnaceBlockEntity holds one per block entity,
BrewingStandBlockEntity another, and CampfireBlock hands one to
CampfireBlockEntity.cookTick. And
RecipeCache, ten entries held statically by CrafterBlock and keyed on the
grid contents, caches misses as well as hits, and invalidates on object
identity: it keeps a weak reference to the manager and wipes itself the moment
the level hands back a different one, which works because a reload builds a new
RecipeManager.
The gate, and then the result. RecipeCraftingHolder.setRecipeUsed returns
false — leaving the result slot empty — when GameRules.LIMITED_CRAFTING is on,
the recipe is not special, and ServerRecipeBook.contains says no. Limited
crafting is enforced here, in the result slot, not in matching. Past it, Recipe.assemble produces the stack — for every crafting recipe but the
hand-written ones it ignores its input entirely and materialises a stored
ItemStackTemplate (items and
stacks) —
ItemStack.isItemEnabled filters that against the level’s feature flags, and
ResultContainer.setItem stores it.
Pushing it. AbstractContainerMenu.setRemoteSlot forces the server’s belief
about slot zero and then a ClientboundContainerSetSlotPacket is sent by hand,
incrementing the state id on its own way past — outside the diffing that
containers and
menus
describes, and outside AbstractContainerMenu.suppressRemoteUpdates, which
guards only that diffing and so cannot reach a hand-written send. It is sent even when the
result is empty.
Taking it, in an order that surprises. ResultSlot.remove counts what was
taken, and then ResultSlot.onTake runs ResultSlot.checkTakeAchievements
before anything is consumed. That calls ItemStack.onCraftedBy, which awards
Stats.ITEM_CRAFTED and runs Item.onCraftedBy, and then
RecipeCraftingHolder.awardUsedRecipes, which fires
CriteriaTriggers.RECIPE_CRAFTED for every recipe, special ones included, and
then — for a non-special recipe only — calls ServerPlayer.awardRecipes and so
ServerRecipeBook.addRecipes, unlocking it, firing
CriteriaTriggers.RECIPE_UNLOCKED, sending ClientboundRecipeBookAddPacket, and
nulling the stored holder. The next thing ResultSlot.onTake does is look
the recipe up all over again — not because of that null, but because
ResultSlot.getRemainingItems never consults the stored holder on any path,
special recipes included. CraftingRecipe.getRemainingItems — whose
default implementation is spelled CraftingRecipe.defaultCraftingReminder, the
typo Mojang’s — maps each slot through Item.getCraftingRemainder, an
ItemStackTemplate or nothing. Then one item leaves each occupied cell and the
remainder goes back into the emptied slot, or merges with what is left there, or
goes to the inventory, or is dropped. Every one of those removals re-enters
AbstractContainerMenu.slotsChanged, so the result slot is recomputed eight
times on the way out.
A crafter block runs the same machinery through RecipeCache and fires its own
advancement trigger, CriteriaTriggers.CRAFTER_RECIPE_CRAFTED, not the player’s.
What the client actually gets
The client’s entire recipe API is two methods. RecipeAccess declares
RecipeAccess.propertySet and RecipeAccess.stonecutterRecipes and nothing
else, and exactly two classes implement it: RecipeManager on the server and
ClientRecipeContainer on the client, the latter holding a map and a set and
having no other content at all. Neither method’s type is a Recipe, but the type is not what keeps recipes off
the wire: on the server the stonecutter set carries a recipe in every entry, and
the codec below is what drops it.
ClientboundUpdateRecipesPacket goes out twice: once from PlayerList as a
player joins, once to everybody from PlayerList.reloadResources. It carries two
things. The RecipePropertySets are flat sets of items, and
RecipePropertySet.test is item identity only — the client’s slot predicate
ignores components entirely. The stonecutter’s
SelectableRecipe.SingleInputSet is written by
SelectableRecipe.SingleInputEntry.noRecipeCodec, which serialises the input
Ingredient’s contents and the option’s SlotDisplay and drops the recipe on
the floor: SelectableRecipe.noRecipeCodec decodes an empty optional in its
place.
Those sets exist so that menus can answer may this item go in this slot — and
route a shift-click on the strength of it — without knowing a single recipe.
The recipe classes behind those stations are as thin as the sets suggest:
SingleItemRecipe is the shape StonecutterRecipe and the cooking recipes extend, and SmithingTransformRecipe
and SmithingTrimRecipe are the smithing table’s two — one that replaces the
item, one that writes an ArmorTrim component onto it.
SmithingMenu builds its three input slots out of the three smithing sets,
AbstractFurnaceMenu holds whichever of RecipePropertySet.FURNACE_INPUT,
RecipePropertySet.BLAST_FURNACE_INPUT and RecipePropertySet.SMOKER_INPUT
its type was constructed with — the fourth set,
RecipePropertySet.CAMPFIRE_INPUT, reaches no menu at all and is read by
CampfireBlock on a right-click — BrewingStandMenu reads the two brewing sets,
and
StonecutterMenu asks the stonecutter set the same question through
SelectableRecipe.SingleInputSet.acceptsInput.
The book gets more, and still no names
The book gets something far richer and still anonymous: a RecipeDisplayEntry
per display, carrying the display id, the RecipeDisplay itself, a group index,
a RecipeBookCategory, and an optional ingredient list used for one thing only — deciding whether the entry
shows as craftable. RecipeDisplay and SlotDisplay are
dispatched registries of their own (Registries.RECIPE_DISPLAY,
Registries.SLOT_DISPLAY, and Registries.RECIPE_BOOK_CATEGORY for the
categories RecipeBookCategories fills), and their stream codecs are very much
used: five RecipeDisplay types, one per station shape, and eleven registered
SlotDisplay variants, which SlotDisplay.resolve turns into concrete stacks against a ContextMap that
SlotDisplayContext builds. The chest’s single ingredient reaches the client
as a SlotDisplay.TagSlotDisplay over minecraft:planks
(tags) — everything needed to draw the recipe, and
nothing at all about its name.
The recipe book: unlocked, glowing, and filled in for you
ServerRecipeBook stores four things: a display resolver, the settings,
ServerRecipeBook.known —
an identity set of recipe keys — and ServerRecipeBook.highlight, the subset
still new enough to glow. It is saved in the player NBT as
ServerRecipeBook.Packed and read back by ServerRecipeBook.loadUntrusted,
which validates every key against the live RecipeManager and logs and drops the
ones that no longer resolve (codecs).
A fresh player’s is genuinely empty, and stays empty until something fills it.
The ordinary route is not crafting at all: every recipe in vanilla that is not
special has an advancement whose reward names it, so unlocking the advancement is what fills
the book (advancements), and a new world’s
opening minutes are that machinery running. Crafting a recipe you had not
unlocked adds it too, which is the path this trace took.
ClientRecipeBook never sees any of that. It holds RecipeDisplayEntrys by
display id, and ClientRecipeBook.rebuildCollections groups them into
RecipeCollections by category and then by group index, which is why one button
in the book cycles through all thirteen kinds of plank.
The tabs are narrower than the recipe types. RecipeBookType has four values —
crafting, furnace, blast furnace and smoker — and exactly five concrete menus
extend RecipeBookMenu to claim them, under two abstract halves:
AbstractCraftingMenu gives CraftingMenu and InventoryMenu, both
answering RecipeBookType.CRAFTING, and AbstractFurnaceMenu gives
FurnaceMenu, BlastFurnaceMenu and SmokerMenu, one per remaining value. StonecutterMenu and SmithingMenu are not
RecipeBookMenus at all, so the stonecutter and the smithing table have no book
and no auto-fill, even though RecipeBookCategories.STONECUTTER and
RecipeBookCategories.SMITHING exist to categorise them — and the anvil and the
grindstone were never recipes at all (enchanting).
Craftability is decided twice
Craftability is decided on the client and then decided again on the server.
RecipeCollection.selectRecipes asks RecipeDisplayEntry.canCraft against a
StackedItemContents that RecipeBookComponent fills from the player’s
inventory, purely to choose which entries show as craftable. A lying client gains nothing by
it, because the placement re-checks and CraftingMenu.slotChangedCraftingGrid
runs a full Recipe.matches afterwards regardless.
Seven gates before a single plank moves
Auto-fill itself runs entirely on the server. Clicking an entry sends
ServerboundPlaceRecipePacket carrying nothing but a container id, a
RecipeDisplayId and a shift flag, and
ServerGamePacketListenerImpl.handlePlaceRecipe puts it through seven gates:
- the player is not a spectator, and the packet’s container id is the open menu’s;
AbstractContainerMenu.stillValidstill holds for the open menu;RecipeManager.getRecipeFromDisplayresolves the index to aRecipeManager.ServerDisplayInfo;ServerRecipeBook.containssays this player has unlocked the parent recipe;- the open menu really is a
RecipeBookMenu; - the recipe’s
PlacementInfo.isImpossibleToPlacesays no — which catchesPlacementInfo.NOT_PLACEABLEand any placement whose ingredients came out empty; - and only then does
RecipeBookMenu.handlePlacementrun.
AbstractCraftingMenu.handlePlacement calls AbstractCraftingMenu.beginPlacingRecipe,
which on a CraftingMenu raises a private flag that makes
CraftingMenu.slotsChanged a no-op while the shuffling happens — a
CraftingMenu override, so the 2×2 grid inside InventoryMenu re-matches on
every single write — and calls
ServerPlaceRecipe.placeRecipe, which counts before it clears: a dry run of
emptying the grid back into the inventory, a tally of what the player has, a
calculation of how many crafts that allows, clamped to the smallest maximum stack size among the items chosen, and only then the real clear and the layout through
PlaceRecipeHelper.placeRecipe. Whether it may drop items in order to clear the
grid is simply Player.isCreative, so for a survival player whose inventory cannot take back what the grid holds the
whole call returns RecipeBookMenu.PostPlaceAction.NOTHING — no
fill and no ghost. When the ingredients merely are not there it returns
RecipeBookMenu.PostPlaceAction.PLACE_GHOST_RECIPE instead, and the server sends
ClientboundPlaceGhostRecipePacket carrying the RecipeDisplay itself.
The filter that greys out a recipe you could craft by hand
One filter runs underneath all of that and catches players out.
Inventory.isUsableForCrafting rejects any stack that is damaged, enchanted or
renamed, and it gates both halves of the auto-fill: the tally, through
StackedItemContents.accountSimpleStack, and the actual pull, through
Inventory.findSlotMatchingCraftingIngredient. Matching does not gate on it at
all — that is what CraftingInput’s direct call to
StackedItemContents.accountStack buys. So the book can grey out a recipe
that a manual craft with those very items would have accepted without
complaint, and the difference between the two is one method name.
Where to look
Read RecipeAccess first. It declares two methods, it is the whole of what a recipe
system has to offer a client, and everything else on this page is an argument
about why it is that short.
Then the server’s side in loading order: ResourceManagerRegistryLoadTask for
the scan, MinecraftServer.reloadResources for the swap and RecipeManager
for the four indexes, with RecipeMap beneath it for the two the store keeps. Recipe is the interface every kind implements, and
ShapedRecipePattern is the one piece of matching worth reading line by line,
for the mirrored pass. CustomRecipe is the nine the book never shows.
For the trace, CraftingMenu.slotChangedCraftingGrid is the whole of what
happens when a plank lands, and ResultSlot.onTake is the whole of what
happens when you take the chest — in that surprising order. ServerPlaceRecipe
is the auto-fill, and Inventory.isUsableForCrafting is the filter under it.
For the client’s half, RecipeDisplay and SlotDisplay are what a recipe
becomes when it may not be itself, and ClientRecipeBook is where they land.
Before this page: containers and
menus, for the
synchroniser that the result slot goes around, and items and
stacks
for ItemStackTemplate.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Enchantments
Verified against Minecraft 26.3 · Part VII · A player hits a zombie with a Fire Aspect sword, and everything that makes the zombie burn is data.
You swing a Fire Aspect sword at a zombie and the zombie catches fire. Go
looking for the class that does that — the one with the on hit, set them
alight method — and there is nothing to find. There are no enchantment
subclasses. The forty-three vanilla enchantments are JSON files in the
built-in data pack, Enchantments is a bag of forty-three registry keys that only five files outside the data generator ever name, and the whole
Fire Aspect is melee only rule is one loot condition, sitting on one
entry of a DataComponentMap that no item ever holds.
That is the pattern the rest of the page is about. An enchantment is a named modifier that holds no code; every behaviour in the game an enchantment can change is something another system asks about at a well-defined moment: one of its component keys, a tag that names it, or its id in loot data.
The cast
| class | what it decides | thread |
|---|---|---|
Enchantment | the record — a description, a definition, an exclusive set and a map of effects. No behaviour of its own | built at data-pack load, read on both sides |
Enchantment.EnchantmentDefinition | what it can go on, what it costs, which slots it counts in | data-pack load |
EnchantmentEffectComponents | the thirty-one keys, one per moment an effect can act | static, both sides |
EnchantmentHelper | the static hook surface — walks stacks and slots, calls into the record, holds no state | Server for every entity and location effect; some entry points, two value effects among them, on the Render thread |
ConditionalEffect / TargetedConditionalEffect | whether this effect fires here, and on whom | Server |
EnchantmentEntityEffect | the thing that finally happens | Server only — the signature demands a ServerLevel |
ItemEnchantments | the id-to-level map on the stack, under one of two components | both sides |
EnchantedItemInUse | the stack, the slot it was found in, its owner and a break callback — the bundle an entity or location effect receives, with a slot that may be empty | Server |
A record with a definition and a bag of components
Enchantment is a record of four things: a description Component, an
Enchantment.EnchantmentDefinition, a HolderSet of enchantments it is
exclusive with, and a DataComponentMap of effects. Enchantment.getEffects
looks a component type up in that map and returns an empty list if it is
absent, which is the whole dispatch mechanism.
The definition is where the enchanting rules live. It carries the supported
items and a narrower, optional set of primary items — both normally item
tags — so Enchantment.isPrimaryItem falls back to the supported set when
the second is absent, while Enchantment.isSupportedItem only ever asks the
first. It carries the weight, the maximum level, two Enchantment.Cost
curves (a base plus a per-level-above-first increment, read by
Enchantment.Cost.calculate), the anvil cost, and a list of
EquipmentSlotGroup — the slots in which this enchantment counts at all,
tested by Enchantment.matchingSlot. The codec bounds the weight to 1–1024
and the maximum level to 1–255, which is Enchantment.MAX_LEVEL;
Enchantment.getMinLevel is the one number that is not data, hardcoded to 1.
What a stack carries is not that record but ItemEnchantments, an
immutable id-to-level map under DataComponents.ENCHANTMENTS — or, for an
enchanted book’s inert set, under DataComponents.STORED_ENCHANTMENTS. It
is changed only through ItemEnchantments.Mutable, whose
ItemEnchantments.Mutable.upgrade merges by maximum and clamps at 255.
EnchantmentInstance, a holder-and-level pair with a
EnchantmentInstance.weight delegate, is what the weighted selection path
is built on — and that path, along with the table, the anvil, the
grindstone, the providers and /enchant, is a separate machine with its own
arithmetic, on enchanting.
Thirty-one keys, three registries and one curve
EnchantmentEffectComponents registers thirty-one component types into
BuiltInRegistries.ENCHANTMENT_EFFECT_COMPONENT_TYPE. Twenty-four hold a
list of ConditionalEffect (or TargetedConditionalEffect); the other
seven are plain — EnchantmentEffectComponents.ATTRIBUTES, two sound
lists, two unconditional values, and the two Unit-valued flags
EnchantmentEffectComponents.PREVENT_EQUIPMENT_DROP and
EnchantmentEffectComponents.PREVENT_ARMOR_CHANGE, true by being present.
Three registries supply the effect objects, and two of the three overlap
almost entirely — which is the only thing about them worth remembering.
EnchantmentValueEffect, the odd one out, has six shapes and modifies a
running number
(AddValue, MultiplyValue, SetValue, RemoveBinomial,
ScaleExponentially and AllOf.ValueEffects). EnchantmentEntityEffect
has fifteen and does something to an entity — Ignite, DamageEntity,
ApplyMobEffect, SummonEntityEffect, AllOf.EntityEffects and ten more.
EnchantmentEntityEffect extends EnchantmentLocationBasedEffect, whose
registry has sixteen entries: those same fifteen ids plus one. That extra one
is attribute, EnchantmentAttributeEffect, which installs an AttributeModifier
(attributes) and is,
beside that registry’s own all_of (AllOf.LocationBasedEffects), the only effect
that is location-based without also being an entity effect.
The curve that scales a level
LevelBasedValue turns a level into a number, and six shapes are in its
dispatch registry (LevelBasedValue.Linear, LevelBasedValue.Clamped,
LevelBasedValue.Fraction, LevelBasedValue.LevelsSquared,
LevelBasedValue.Exponent, LevelBasedValue.Lookup).
LevelBasedValue.Constant is deliberately not among them: it is the
other arm of an either-codec, which is what makes a bare float legal
anywhere a curve is expected.
And the condition twenty-four keys sit behind
ConditionalEffect is an effect plus an optional LootItemCondition.
TargetedConditionalEffect adds two EnchantmentTarget fields — which side
of the fight the enchantment lives on (TargetedConditionalEffect.enchanted)
and which side it lands on (TargetedConditionalEffect.affected) —
except for the equipment-drops variant, whose codec reads only the first and
pins the second to EnchantmentTarget.VICTIM.
Both implement Validatable, and the effect codecs are one of only two places
in the game where a context mismatch is a hard error at decode time rather
than a logged warning. The other is VillagerTrade, and nothing else in the
game gets the treatment (contexts and
predicates
owns the three ways a parameter goes missing and the two that are fatal).
The consequence here is worth the trip: a post_attack effect asking about a
block state fails to load, rather than failing quietly at runtime a thousand
hits later.
Seven families of moment
Everything above does nothing until something calls EnchantmentHelper. The enchantment package is called from all over the game, so the artefact worth keeping is a table of who calls what: every entry point with
its callers is the enchantment hook
table, and these are the seven kinds of moment its effect hooks fall into; the rest of the table stores, looks up or chooses enchantments.
| family | a representative hook or two | who makes it real |
|---|---|---|
| damage and protection | EnchantmentHelper.modifyDamage, EnchantmentHelper.getDamageProtection | ServerPlayer.getEnchantedDamage, LivingEntity.getDamageAfterMagicAbsorb |
| post-attack effects | EnchantmentHelper.doPostAttackEffectsWithItemSource, EnchantmentHelper.doPostPiercingAttackEffects | Player.itemAttackInteraction, LivingEntity.postPiercingAttack |
| durability and drops | EnchantmentHelper.processDurabilityChange, EnchantmentHelper.processEquipmentDropChance | ItemStack.hurtAndBreak, Mob.dropCustomDeathLoot |
| projectiles and the weapon in hand | EnchantmentHelper.processProjectileCount, EnchantmentHelper.getPiercingCount | ProjectileWeaponItem.draw, the AbstractArrow constructor |
| location and tick effects | EnchantmentHelper.runLocationChangedEffects, EnchantmentHelper.tickEffects | LivingEntity.onChangedBlock, LivingEntity.baseTick |
| experience and repair | EnchantmentHelper.processBlockExperience, EnchantmentHelper.modifyDurabilityToRepairFromXp | Block.tryDropExperience, ExperienceOrb.repairPlayerItems |
| the flag questions | EnchantmentHelper.has, EnchantmentHelper.hasTag | ArmorSlot.mayPickup for Curse of Binding, four blocks for the four prevents tags |
The location row is the one that keeps state:
LivingEntity.activeLocationDependentEnchantments remembers which
location-based effects are running in which slot, so the system can tell
became active from still active, and so an attribute effect can be taken
off cleanly by EnchantmentHelper.stopLocationBasedEffects when the armour
comes off.
How one hook fires
Most hooks in those seven rows are the same shape underneath. A system reaches a
moment and asks EnchantmentHelper; the helper picks which stacks to walk,
filtering by slot if it was given one; the record picks which effect entries
apply; a loot condition decides whether this one fires. Only then does
anything happen. The flag row is the exception and is the reason the shape is
worth drawing: EnchantmentHelper.has and EnchantmentHelper.hasTag build no context and run no
condition: the first asks the record whether the key is present at all, the second
whether the enchantment is in a tag.
flowchart TD
Caller["a system reaches a moment"] --> EH{"which EnchantmentHelper entry point"}
EH -- "EnchantmentHelper.has, EnchantmentHelper.hasTag" --> Flag["ask the record or its tags: is it there at all"]
EH -- "any of the other six" --> Walk["walk one stack, or every EquipmentSlot of one entity"]
Walk --> Slot["on the slot-aware walk, keep entries Enchantment.matchingSlot accepts"]
Slot --> Comp["read the list under one EnchantmentEffectComponents key"]
Comp --> Target["for the targeted keys, keep entries whose enchanted target matches"]
Target --> Ctx["build a LootContext on that hook's parameter set"]
Ctx --> Cond{"ConditionalEffect.matches runs the LootItemCondition"}
Cond -- "no" --> Drop["nothing happens"]
Cond -- "yes" --> Apply["fold a value, or run the effect on the affected entity"]
One shape, and the flag row’s way round it. The flag row answers from the record and its tags alone, with no context built and no condition run.
The LootContext in the middle is the same machinery loot tables and
/execute if predicate use, and Enchantment builds five of them —
Enchantment.damageContext plus four private siblings for items, entities,
locations and block hits, one per parameter set the components name.
Contexts and predicates
owns that half.
Fire Aspect, from the click to the flame
Fire Aspect as data is one small JSON file: supported and primary items
are tags, weight 2, maximum level 2, anvil cost 4, dynamic cost curves, one
slot (mainhand), and one effect — a
EnchantmentEffectComponents.POST_ATTACK entry whose enchanted target is
the attacker, whose affected target is the victim, whose effect is Ignite
with a linear duration of four seconds per level, behind one damage-source
predicate requiring a direct hit.
sequenceDiagram
participant SGPL as ServerGamePacket<br/>ListenerImpl
participant Player as Player
participant EH as EnchantmentHelper
participant Ench as Enchantment
participant Ignite as Ignite
participant Entity as Entity
SGPL->>Player: attack, once handleAttack has passed the range checks
Player->>Player: createAttackSource, whose direct and causing entity are one
Player->>EH: doPostAttackEffects<br/>WithItemSource,<br/>on a hit that landed
EH->>EH: the victim's whole equipment first, then the attacker's main hand
EH->>Ench: doPostAttack for the main-hand stack, in the ATTACKER pass
Ench->>Ench: TargetedConditional<br/>Effect.matches asks <br/>if the hit was direct
Ench->>Ignite: apply, with the victim as the affected target
Ignite->>Entity: igniteForSeconds, raised only if the new value is larger
rect rgba(0, 0, 0, 0.04)
Note over Ignite,Entity: a later tick, and every tick after it while the fire lasts
Entity->>Entity: baseTick sets the shared flags byte through SynchedEntityData
Note over Entity: the flame travels as a data packet, the enchantment never does
end
Six lanes between the click and the flame. The band is where the enchantment has already finished —
what the other player sees is a SynchedEntityData flag, and one point of damage a
second is the entity’s own business.
The swing, and the source that is the whole melee rule.
ServerGamePacketListenerImpl.handleAttack runs the range checks and calls
Player.attack — unless the main-hand item has
DataComponents.PIERCING_WEAPON, in which case the packet is dropped and
none of this happens (see the spear
for its own path). Player.createAttackSource then asks
ItemStack.getDamageSource, and every branch of it reaches the
single-entity DamageSource constructor — the one that sets the direct
entity and the causing entity to the same object, making
DamageSource.isDirect true. An arrow’s source has an arrow as the direct
entity and a player as the causing entity, so it is false. That one
comparison, read by one loot condition in one JSON file, is why Fire Aspect
never fires through a bow.
The damage, then the hook. ServerPlayer.getEnchantedDamage — the
override, not Player’s base version, which returns its argument unchanged
— folds Sharpness and friends in through EnchantmentHelper.modifyDamage,
and the hit goes through Entity.hurtOrSimulate to LivingEntity.hurtServer
(damage and death). Only if that returned
true does Player.itemAttackInteraction call
EnchantmentHelper.doPostAttackEffectsWithItemSource.
The one method has three branches. The helper walks the victim’s whole
equipment first, with EnchantmentTarget.VICTIM as the pass — that is
Thorns’ lane, and why Thorns works from a chestplate while Fire Aspect does
not work from boots. Then, for a living causing entity, it walks the one weapon stack it was
handed, in the EnchantmentTarget.ATTACKER pass, keeping enchantments whose
declared slots include the main hand. Its third branch
takes a causing entity that is not living: a slotless walk with no filter at
all, which only fires when a break callback was supplied, and whose only
vanilla caller is ThrownTrident. An attacker’s armour can never
contribute a post-attack effect.
That second branch is worth one more sentence, because the slot it names is a
label rather than a fact. It hands EquipmentSlot.MAINHAND to the filter
whatever the weapon actually is, and so does
EnchantmentHelper.doPostPiercingAttackEffects; KineticWeapon.damageEntities
reaches Player.stabAttack (a mob’s LivingEntity.stabAttack) with whichever
slot the use was in. An
off-hand spear’s enchantments are therefore tested against the main-hand slot
group. The slot question itself is asked two ways: the two
EnchantmentHelper.forEachModifier overloads test different things — the
EquipmentSlot one asks Enchantment.matchingSlot, the EquipmentSlotGroup
one asks whether the definition declares that exact group — and
ItemStack.forEachModifier uses one of each. One more pair like it is worth
knowing before you grep: the instance method is spelled
Enchantment.modifyArmorEffectivness, Mojang’s typo, while the helper beside
it is EnchantmentHelper.modifyArmorEffectiveness.
The condition and the target. Enchantment.doPostAttack keeps the
entries whose enchanted target matches the pass, builds the
enchanted-damage context — victim, level, origin, damage source, and both
attacking entities as optional parameters — and runs the predicate. The
affected field then picks who receives the effect: attacker, direct
entity, or victim. Fire Aspect says victim, and if that works out to null
the effect is dropped in silence.
The burn. Ignite.apply calls Entity.igniteForSeconds, which floors
to ticks and hands Entity.igniteForTicks a number it applies only if it
is larger than the counter already there — while clearing any freeze
regardless, so re-hitting a burning target with a weaker Fire Aspect does
nothing except thaw it. No damage happens here. Entity.baseTick deals it,
one point every twentieth tick, on a ServerLevel, skipped in lava and
replaced by Entity.clearFire for a fire-immune entity. The same method
sets shared flag zero through Entity.setSharedFlagOnFire
(synched entity
data),
which becomes
Entity.displayFireAnimation on the client — whose own Entity.baseTick,
finding no ServerLevel, clears the fire counter instead of burning.
Three famous ones driven from the other end
The three enchantments players talk about most are driven from the other end: something else finds each one on the stack and does the famous work, and only one of the three has no effect component at all.
Fortune lives in the loot table. ApplyBonusCount and
BonusLevelTableCondition read the tool parameter out of a LootContext
and call EnchantmentHelper.getItemEnchantmentLevel on it — a level, not an
effect. Looting is the same trick twice more:
EnchantedCountIncreaseFunction and
LootItemRandomChanceWithEnchantedBonusCondition both read the attacking
entity parameter and call EnchantmentHelper.getEnchantmentLevel, which walks the slots of a LivingEntity the enchantment counts in and keeps the
best. Fortune has no effect component whatsoever, and Looting’s only one is an
equipment_drops entry, which raises the chance a mob drops its equipment and
plays no part in its loot table
(loot tables).
Mending inverts it once more: ExperienceOrb.repairPlayerItems asks
EnchantmentHelper.getRandomItemWith for a damaged stack carrying
EnchantmentEffectComponents.REPAIR_WITH_XP, so the orb drives the repair
and the item is found. What runs on the item is one number: its
EnchantmentEffectComponents.REPAIR_WITH_XP entry, a multiply by two that
EnchantmentHelper.modifyDurabilityToRepairFromXp applies, makes each point of
experience buy two points of durability.
What runs on the client, and why no entity effect does
No entity or location effect can: EnchantmentEntityEffect and
EnchantmentLocationBasedEffect both demand a ServerLevel in their
signatures, which is the same mechanical guarantee the loot system uses. But
two values do. Enchantment.modifyUnfilteredValue takes only a
RandomSource, and its two users are Enchantment.modifyCrossbowChargeTime
and Enchantment.modifyTridentSpinAttackStrength.
Both are visible as gameplay. CrossbowItem.getChargeDuration is called by
three entity renderers, by FirstPersonHandsAndItems and by the CrossbowPull item
property, so Quick Charge is evaluated on the Render thread every frame a
crossbow is being drawn — an enchantment hook in the frame loop, to pose an arm and pick one of three
textures. And MultiPlayerGameMode.releaseUsingItem runs
TridentItem.releaseUsing on the client’s own copy, which asks
EnchantmentHelper.getTridentSpinAttackStrength, so Riptide’s strength is
computed client-side too, which is what lets the riptide push be predicted
at all (using an item).
The rest is drawing, or a question with no effect behind it. The drawing is
ItemEnchantments.addToTooltip for the tooltip, ItemStack.hasFoil for the
glint, and EnchantmentHelper.forEachModifier for the attribute lines, which
means the client evaluates the LevelBasedValue curve itself. The questions are Curse of Binding’s flag, which
ArmorSlot.mayPickup and Equippable.swapWithEquipmentSlot ask while a click or
a use is predicted, and Riptide’s sound, which
EnchantmentHelper.pickHighestLevel chooses before the ServerLevel test. And
the anvil and the grindstone build their results on the client’s copy of the menu
too, with the same helpers the server uses. That is what the cast’s some entry
points on the Render thread covers.
Where the forty-three live, and when they are read
At world load, and not again. Registries.ENCHANTMENT and
Registries.ENCHANTMENT_PROVIDER are both in
RegistryDataLoader.WORLD_REGISTRIES, the dynamic registries built once
when a world opens (identifiers and
registries) —
not in RegistryLayer.RELOADABLE, where the loot tables and predicates live.
/reload rebuilds those and every recipe and leaves the enchantments themselves
as the world found them, their tags aside, which it re-reads with every other
registry’s; changing an enchantment takes a restart. That is the sharpest
difference between this part’s three engines, and the one a data-pack author
meets first.
What crosses the wire is usually an id and nothing else.
Registries.ENCHANTMENT is in RegistryDataLoader.SYNCHRONIZED_REGISTRIES
with the full Enchantment.DIRECT_CODEC, but
RegistrySynchronization.packRegistry sends a bare Identifier for every
element whose pack the client already has — for a vanilla client against a
vanilla server, all forty-three. Full definitions cross only for a data pack’s
custom or overridden enchantments. Beyond that,
ClientboundUpdateTagsPacket for EnchantmentTags and, per stack, the
DataComponents.ENCHANTMENTS component: registry ids and levels.
One JSON file, four effect objects, no Java
Enchantments.LUNGE is the only user of
EnchantmentEffectComponents.POST_PIERCING_ATTACK in the game, and it is the
whole pattern in one file. Its single effect is an AllOf.EntityEffects of
four — a ChangeItemDamage, an ApplyExhaustion scaled per level, an
ApplyEntityImpulse forward with its vertical component scaled away, and a
PlaySoundEffect holding three sounds, indexed by level rather than shuffled
— behind a four-clause condition checking that the user is not riding, not
elytra-flying, not in water, and either not a player, in creative, or fed.
Four effect objects, four predicates, no Java, and a lunge that shoves you forward and tires you out. That is what an enchantment is.
Questions players ask
Why is an enchanted book inert? Because every effect hook reads DataComponents.ENCHANTMENTS and nothing else,
through the private walks (EnchantmentHelper.runIterationOnItem among them) or,
for Mending’s EnchantmentHelper.getRandomItemWith, directly. A book’s set lives
under DataComponents.STORED_ENCHANTMENTS, and the routing between the two
is keyed on the exact item Items.ENCHANTED_BOOK
(enchanting owns that routing).
The component is not so exclusive: ItemStack puts
DataComponents.STORED_ENCHANTMENTS in any stack’s tooltip, and the anvil
prices its right-hand input as a book by testing for that component rather
than for the item (enchanting)
— so a data pack can make a stick priced like one, but never make it behave
like one.
Does Fire Aspect cook the loot? Not through the enchantment. The cooking
is SmeltItemFunction, an ordinary loot function on the mob’s own table,
behind a condition that passes when the victim is on fire or the direct
attacker’s main hand carries an enchantment in EnchantmentTags.SMELTS_LOOT —
a tag whose only member is Fire Aspect (loot
tables). So the sword you swung is read twice
by two systems that know nothing about each other: once for its effect
component, and once, much later, as a name in a tag.
Where to look
Enchantment is four fields and will take ten minutes; read it first, because
everything else on this page is about how those four fields are consulted.
Enchantment.EnchantmentDefinition inside it is the enchanting rules, and
EnchantmentEffectComponents beside it is the list of thirty-one moments.
Then EnchantmentHelper, which is the entire behaviour surface and holds no
state. Read the private EnchantmentHelper.runIterationOnItem and its slot-aware
siblings before any public entry point: they are the walk, and most hooks above
them are a thin wrapper around one of them. The hook
table is the map of which callers
reach which.
For the effect side, ConditionalEffect and TargetedConditionalEffect are the two shapes a
conditional entry takes, and Ignite is the smallest effect
worth reading — three lines that set an entity on fire. LevelBasedValue is the curve underneath most of them.
For what a stack carries rather than what an enchantment is,
ItemEnchantments; for the selection machinery this page deliberately leaves
alone, EnchantmentInstance and then enchanting.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Enchanting: the five paths, and what each one is allowed to do
Verified against Minecraft 26.3 · Part VII · A player reads three offers off an enchanting table and buys one, and then the same sword meets the four other paths that change what it is enchanted with — an anvil, a grindstone running backwards, a spawning pillager, and a command.
A sword goes in the left slot of an enchanting table and three lapis in the
right, and the table answers with three lines of Standard Galactic Alphabet,
three level numbers, and — if you hover — one enchantment named outright.
None of that is guessed. The server has already run the entire selection, and
the client holds the answer as ten numbers the menu syncs. One of those ten is
Player.enchantmentSeed, and it is the reason the page is worth a lecture:
one number per player, saved in the player file, carried across death and
dimension change, sent to the client, and re-rolled by nothing in the game but the enchanting table itself and a load
that finds it still zero. Spend thirty levels at an anvil and
come back and the table is offering exactly what it offered before — and the
gibberish is in the same handwriting, because the client is drawing it from the
low sixteen bits of that same number.
The table is one of five paths that change what a stack is enchanted with —
four of them adding and the grindstone taking away — and they differ far more
than the shared vocabulary suggests. This page is about those differences. A
sixth writer hides outside all of them, in the crafting grid: RepairItemRecipe
carries every curse from both inputs onto the item it makes
(recipes). What an enchantment is — the record, the effect
components, the hooks that fire in combat — is
the next page along
and is not re-taught here.
The cast
| class | what it decides | thread |
|---|---|---|
EnchantmentMenu | the three offers, the clue, and what the click costs | Server, with a Render-thread copy that can only say no |
EnchantmentHelper | the cost curve, the weighted selection, and the write every path ends in | whichever side asks |
Player | the seed and the levels | Server |
AnvilMenu | the merge arithmetic and the price | Server |
GrindstoneMenu | the removal made on purpose, and the refund | Server |
EnchantmentProvider | what a mob’s spawn equipment gets | Server |
EnchantRandomlyFunction | chest loot and villager trades | Server |
EnchantCommand | the operator’s path, and the fewest checks | Server |
EnchantWithLevelsFunction, SetEnchantmentsFunction and
EnchantedCountIncreaseFunction are the other three loot functions, named
below where they differ; EnchantmentScreen is the client half of the table
and gets its own section.
The five paths at a glance
Read this table as the page’s table of contents rather than as something to use now: every cell is a compressed forward reference, and each row below is the section that unpacks it.
| enchanting table | anvil | grindstone | providers and loot | /enchant | |
|---|---|---|---|---|---|
| what it costs | 1, 2 or 3 levels and the same count of lapis | the full price in levels, and a chance the anvil chips | pays you, in orbs at the block | nothing | nothing |
| the gate on the item | ItemStack.isEnchantable — enchantable and not already enchanted | EnchantmentHelper.canStoreEnchantments | damageable or already enchanted | DataComponents.ENCHANTABLE, with three exceptions (below) | any non-empty main-hand item |
| which item filter | Enchantment.isPrimaryItem — the narrow set | Enchantment.canEnchant — the supported set | n/a | one of three, depending on the path (below) | Enchantment.canEnchant |
| the level ceiling | whatever the cost brackets allow | clamped to Enchantment.getMaxLevel | n/a | clamped, except SetEnchantmentsFunction | rejected above Enchantment.getMaxLevel |
| exclusivity | filtered out mid-selection | dropped, and it raises the price | curses survive, everything else goes | filtered among the cost-based picks, unasked by the rest | rejected with an error |
| randomness | the player’s saved seed | none in the arithmetic; a 12% roll for the chip | none in the strip; a roll on the refund | the random source its caller hands in | none |
| decided on | server, with the click predicted | server, with the price synced | server | server | server |
Every row of that table differs. The one thing that would not — and so has no
row — is the last step, where all five converge. Not on the same method: the table, /enchant, EnchantRandomlyFunction and EnchantWithLevelsFunction go
through ItemStack.enchant, the grindstone, SetEnchantmentsFunction and the
providers (through EnchantmentHelper.enchantItemFromProvider) call
EnchantmentHelper.updateEnchantments, and the anvil writes with
EnchantmentHelper.setEnchantments. But on the same decision, and that is
where the page starts.
The one question all five ask
The three write methods above funnel into one private line,
EnchantmentHelper.getComponentType, so all five paths ask it in the end —
and it does the thing worth knowing before any of them make sense.
It routes by item identity: the component the write lands in is
DataComponents.STORED_ENCHANTMENTS if the stack is Items.ENCHANTED_BOOK
and DataComponents.ENCHANTMENTS otherwise — a hard identity test against
one item, not a tag. That is why every path that can be handed a plain Items.BOOK transmutes it
first, a creative player’s anvil apart, through ItemStack.transmuteCopy or by
building a fresh stack. The transmute is not cosmetic: enchant a plain book
and the levels would land in the active component and the book would start
working.
EnchantmentHelper.updateEnchantments adds one more rule of its own: it
silently does nothing if the component is absent, returning
ItemEnchantments.EMPTY on a null read. In practice every item gets
DataComponents.ENCHANTMENTS from DataComponents.COMMON_ITEM_COMPONENTS,
so the case arises only when a patch removes the component from a stack, and then
the table and the anvil refuse the item and every other path becomes a no-op,
with no error anywhere (data
components).
The merge itself is ItemEnchantments.Mutable.upgrade: keep the higher
level, cap at 255, ignore a level of zero. Only
ItemEnchantments.Mutable.set can lower a level, and only the anvil — where it clamps an over-maximum level down —
SetEnchantmentsFunction and the crafting grid’s RepairItemRecipe reach for it. The grindstone does not lower anything; it removes.
What it costs, and who pays
The enchanting table’s headline number is not its price. The level
requirement for a slot is the cost the table computed for it — up to
thirty at the bottom slot — but the amount Player.onEnchantmentPerformed
actually subtracts is the slot’s index plus one, and the lapis consumed is
the same one, two or three.
| what the forum says | what the decompile does |
|---|---|
| the bottom offer costs thirty levels | EnchantmentMenu.clickMenuButton requires thirty levels and takes three |
| more bookshelves make better enchantments | more bookshelves raise the cost, which is what buys higher levels and extra enchantments, and EnchantmentHelper.getEnchantmentCost floors the bottom slot at twice the shelf count |
| the anvil’s “Too Expensive” is a level cap | it is a result cap — at a price of forty or more AnvilMenu.createResult empties the output slot unless the player has infinite materials |
The anvil charges what it shows, and taxes what came before
The anvil is the opposite: it charges the whole displayed price, through
Player.giveExperienceLevels with a negative amount, in AnvilMenu.onTake,
and then rolls a small chance to damage or destroy the block. The price is
a prior-work tax read from DataComponents.REPAIR_COST on both inputs,
plus one per repair material consumed, plus two when merging with a second item of its kind repairs it, plus Enchantment.getAnvilCost times
the resulting level for every enchantment transferred (halved with a floor
of one when the addition is a book), plus one for a rename or for clearing a name. The larger of
the two inputs’ DataComponents.REPAIR_COST is then doubled and incremented
by AnvilMenu.calculateIncreasedRepairCost and written onto the result — the
whole of the prior-work spiral, and the one step a pure rename skips. Two cases escape that arithmetic: an input stack of more than one item sets the price to 40, before the
prior-work tax is added, the moment any enchantment transfers, which — forty being exactly the threshold at which the result is
withheld — makes enchanting a stack not expensive but forbidden outside
creative; and a rename with no other change is capped at 39, which is why
renaming never hits “Too Expensive”.
The grindstone pays you
The grindstone runs the transaction backwards. It strips everything not in
EnchantmentTags.CURSE, turns an emptied Items.ENCHANTED_BOOK back into a
plain Items.BOOK with ItemStack.transmuteCopy, and rebuilds
DataComponents.REPAIR_COST from zero, so a clean item leaves with its
prior work erased. The refund is the sum of Enchantment.getMinCost at each
stripped level, halved upward with a random bonus of up to one less than that
half again,
and it arrives as orbs from ExperienceOrb.award at the block — on the
ground, not in the player
(hunger and
experience).
What each path is allowed to add
Two predicates are doing the work, and the difference between them is the difference between the enchantments an axe is offered and the enchantments an axe can hold.
Enchantment.canEnchant asks whether the item’s type is in the
definition’s supported set; Enchantment.isSupportedItem asks exactly the
same question of a stack and is called from nowhere but
Enchantment.isPrimaryItem, which asks the supported question and the
narrower falling back to the supported set when the
definition names no primary items. The narrow one lives in
EnchantmentHelper.getAvailableEnchantmentResults, which only
EnchantmentHelper.selectEnchantment calls — so the table, the cost-based providers and EnchantWithLevelsFunction all use it,
and the anvil and /enchant do not. That
is the three-way split the table’s which item filter cell defers: the
selection paths ask the narrow question, EnchantRandomlyFunction asks the supported one when its only_compatible flag
is on and the target is not a plain book, and SingleEnchantment and SetEnchantmentsFunction ask neither. The three exceptions in the gate row are the three that do not ask the narrow
question: each skips DataComponents.ENCHANTABLE. In
vanilla exactly
five enchantments declare a narrower primary set than their supported one.
Three of them are melee enchantments whose supported set reaches axes and
whose primary set stops at swords and spears, which is why no enchanting
table has ever offered Sharpness on an axe while every anvil will put it
there. A fourth does the same to the mace, and the fifth is Thorns, offered
only on a chestplate and wearable anywhere.
The anvil uses Enchantment.canEnchant on its left slot, overridden to
true when that target is an Items.ENCHANTED_BOOK or the player has infinite
materials, so books collect anything. Its right slot is judged
differently, and by a different question: whether the addition is a book, for
the halved price, is DataComponents.STORED_ENCHANTMENTS being present rather
than the item being Items.ENCHANTED_BOOK. One side tests the item, the other
tests the component. Its arithmetic per transferred enchantment is short: the
same level on both sides merges to one higher, different levels take the
maximum, and the winner is clamped to Enchantment.getMaxLevel. An
enchantment the target cannot take is dropped and costs nothing; one
that conflicts with something already on the result is dropped and adds one to the price per conflicting pair — the anvil’s one punitive rule
besides the stack rule above. If
nothing survives, the result slot is emptied.
The ceilings, and who ignores them
/enchant is the shortest path and, contrary to its reputation, not the
laxest. EnchantCommand rejects a level above Enchantment.getMaxLevel
before it looks at any target, then per target requires a LivingEntity
whose LivingEntity.getMainHandItem is non-empty, then checks
Enchantment.canEnchant and EnchantmentHelper.isEnchantmentCompatible
against what the stack already carries — and from there it is the same tail
as everything else. What it skips is the primary filter, the enchantability
component and the cost, not the supported-items or level rules. It also
accepts a level of zero, which passes every check, reports success, and
changes nothing.
The genuine ceiling-breaker is elsewhere. SetEnchantmentsFunction writes
through ItemEnchantments.Mutable.set, whose only clamp is 255, with no
reference to Enchantment.getMaxLevel at all: a loot table can hand out
Sharpness 200 and nothing else on this page can.
Exclusivity, one test wherever it is asked
Where it is asked at all, exclusivity is one static method —
Enchantment.areCompatible, wrapped by
EnchantmentHelper.isEnchantmentCompatible and
EnchantmentHelper.filterCompatibleEnchantments — and it is symmetric,
failing if either side’s exclusive set names the other, and failing an enchantment
against itself. Only the anvil and /enchant ask it against what the stack
already carries; the selection asks it only among its own picks, and
EnchantRandomlyFunction, SingleEnchantment and SetEnchantmentsFunction never
ask it.
Where the randomness comes from
The anvil and the grindstone roll a die each — for the chip and for the refund
— and everything else but /enchant (and, with vanilla’s constant levels,
SetEnchantmentsFunction) rolls one to decide
what you get. But not in the same place. Six things roll what an item gets in
this game, and four of them reach
the shared arithmetic in EnchantmentHelper.selectEnchantment: the table, the
two cost-based providers, and EnchantWithLevelsFunction through
EnchantmentHelper.enchantItem. The remaining two roll their own and much
more simply — SingleEnchantment samples a level for an enchantment it
already knows, and EnchantRandomlyFunction picks one at random off the loot context’s random
source. So the arithmetic below is the cost-based path, and it is a
short method with four distinct sources of variance stacked on one another —
the last of which is a fresh roll from fifty, tested against the cost, once
per extra enchantment.
flowchart TD
A["a cost arrives, from the table or from a provider"] --> B{"does the stack have DataComponents.ENCHANTABLE"}
B -- no --> Z["empty list, and the caller adds nothing"]
B -- yes --> C["raise it by two rolls scaled by Enchantable.value"]
C --> D["scale by a triangular span of 15 percent, clamp at 1"]
D --> E["EnchantmentHelper.getAvailableEnchantmentResults: primary here, or a plain book"]
E --> F["per survivor, the highest level whose bracket holds the value"]
F --> G{"any candidates at all"}
G -- no --> Z
G -- yes --> H["weighted pick by Enchantment.getWeight"]
H --> I{"a roll from fifty is at most the cost"}
I -- no --> Y["the list, out"]
I -- yes --> J["drop every candidate incompatible with the last pick"]
J --> K{"anything left"}
K -- no --> Y
K -- yes --> L["weighted pick again, then halve the cost"]
L --> I
Four sources of variance in a row, and then a loop. The cost halves on every pass through it, which makes each extra enchantment rarer than the last; the first extra roll, taken before any halving, is certain from a cost of forty-nine.
The enchantability perturbation is the first place the item matters:
Enchantable.value — gold’s is famously high — widens two independent rolls
that only ever push the cost up. The span that follows is triangular
rather than flat, so the extremes are rare. The bracket test in
EnchantmentHelper.getAvailableEnchantmentResults walks levels downward and
stops at the first fit, so a high cost buys a high level of one enchantment
rather than more of them. Buying more is the loop’s job: the cost halves
after every extra pick, so by the fourth pass the roll is nearly always lost — while from a cost of forty-nine up the first extra roll is certain.
The table adds one more layer. EnchantmentMenu.slotsChanged seeds its
RandomSource with Player.enchantmentSeed for the three costs, then
re-seeds it with the seed plus the slot number before each selection,
which is why the three offers are independent of each other and yet
reproducible. EnchantmentHelper.getEnchantmentCost returns zero outright
for an item with no DataComponents.ENCHANTABLE, and a slot whose cost came
out below its own index plus one is zeroed too. The weight each surviving
candidate carries into the pick is Enchantment.getWeight, straight off the
data-pack record.
Bookshelves reach it as a plain integer. EnchantmentMenu walks
EnchantingTableBlock.BOOKSHELF_OFFSETS — a fixed list of thirty-two
offsets, the outer ring of a five-by-five footprint at two heights — and
EnchantingTableBlock.isValidBookShelf requires the block at the offset to
be in BlockTags.ENCHANTMENT_POWER_PROVIDER and the block between it
and the table to be in BlockTags.ENCHANTMENT_POWER_TRANSMITTER
(tags). That between position halves the X and Z
offsets but leaves Y alone, so the upper ring’s gap is checked at the
bookshelf’s own height, not the table’s. The clamp to fifteen happens
inside EnchantmentHelper.getEnchantmentCost, not in the walk.
What is decided on which side
The clue you hover is not a hint about what you might get: it is a genuine
member of the exact list you will get. EnchantmentMenu.slotsChanged runs
the selection for real, shows one entry of the result at random and throws
the rest away, and EnchantmentMenu.clickMenuButton runs the same selection
again from the same seed and slot and applies all of it. The one wrinkle is
the plain book, which has one random entry deleted from its list before either
use — unless the list has only one entry, which survives.
sequenceDiagram
box transparent your machine
participant EScr as EnchantmentScreen
participant CEM as EnchantmentMenu
end
participant Wire as the network
box transparent the server
participant EM as EnchantmentMenu
participant EH as EnchantmentHelper
end
Note over EM,EH: slot 0 fills, and <br/>EnchantmentMenu.<br/>slotsChanged runs on this side
EM->>EM: walk BOOKSHELF_OFFSETS, count the valid shelves
EM->>EH: getEnchantmentCost three times, from the player seed
EM->>EH: selectEnchantment per slot, re-seeded with the seed plus the slot
EH-->>EM: a list per slot, one entry of which becomes the clue
EM->>Wire: one<br/>Clientbound<br/>ContainerSetDataPacket<br/>per changed slot, from broadcastChanges
Wire->>CEM: the costs and the clues, the seed there since the menu opened
Note over EScr,CEM: EnchantmentNames.<br/>initSeed fixes <br/>the alphabet for this seed
EScr->>CEM: clickMenuButton, whose level access here is NULL
CEM-->>EScr: true only if the lapis and the levels are there, or materials are infinite
EScr->>Wire: ServerboundContainerButtonClickPacket, via MultiPlayerGameMode
Wire->>EM: clickMenuButton, the identical call on the server's copy
EM->>EH: selectEnchantment again, same seed and slot, same list
Note over EM,EH: Player.onEnchantmentPerformed takes the levels and re-rolls the seed
EM->>EH: updateEnchantments once per entry, through ItemStack.enchant
Note over EM,EH: the lapis goes, Stats.ENCHANT_ITEM, CriteriaTriggers.ENCHANTED_ITEM
EM->>EM: slotsChanged again, and an enchanted item gets no offers
EM->>Wire: the zeroed costs, the cleared clues and the new seed, from the handler's broadcastChanges
One menu class, two copies, and the boundary drawn. The client’s EnchantmentMenu
answers the click before any packet is sent, from the cost it was sent, the item and lapis in its slots, and the player’s
levels or infinite materials.
The predicted click is the sharpest thing on that diagram.
EnchantmentScreen.mouseClicked calls EnchantmentMenu.clickMenuButton on
its own local menu and only sends the packet — a
ServerboundContainerButtonClickPacket, through MultiPlayerGameMode — if
that call returns true. The server’s run of the same method is where
Stats.ENCHANT_ITEM is awarded and CriteriaTriggers.ENCHANTED_ITEM fires. On
the client the menu’s level access is ContainerLevelAccess.NULL, whose
evaluation returns an empty optional without running the action at all — so
the entire enchanting body is skipped, and what the client really evaluates
is the guard in front of it: the lapis count, the level requirement, and
Player.hasInfiniteMaterials. The affordability check is real on both
sides; the enchanting is real on one.
Each changed value crosses as one
ClientboundContainerSetDataPacket.
Containers and menus
has the data-slot channel, and
where a broadcast happens
has the button click’s own timing.
All ten are ordinary DataSlot entries, and nine of them are
DataSlot.shared views onto three arrays of three: the costs, the enchantment
ids, and the levels. The tenth is the one DataSlot.standalone, holding the
seed. So a clue is a pair — which enchantment, and at what level — which is
why three offers cost six slots, and why the tooltip can say Sharpness III
rather than just Sharpness. A clue with nothing to show carries −1, and a cost 0. They
reach the client one ClientboundContainerSetDataPacket each as
AbstractContainerMenu.broadcastChanges diffs them against its remote copy.
The enchantment half of each pair is a numeric registry id that
EnchantmentScreen resolves against its own registry copy — the same registry copy that
ItemEnchantments’ stream codec needs to name the enchantments on any stack
the client is sent. The seed slot is
the only route by which Player.enchantmentSeed ever reaches a client, and what
crosses is its low sixteen bits: it is written to the player file as XpSeed,
re-rolled on read if it loads back as zero, copied unconditionally by
ServerPlayer.restoreFrom when a respawn builds a new player (death, or the way
out of the End), and named in no packet of its own.
EnchantmentNames.initSeed then seeds one shared RandomSource with it per
frame and EnchantmentNames.getRandomName draws three or four words from a
fixed list in the alt font — same seed, same three lines, every time.
The paths that never show a player anything
The provider path, at spawn
Mob.enchantSpawnedEquipment
(entity lifecycle)
calls
EnchantmentHelper.enchantItemFromProvider, which looks a provider up in
Registries.ENCHANTMENT_PROVIDER and hands the stack’s mutable enchantment
map to EnchantmentProvider.enchant. Registries.ENCHANTMENT_PROVIDER is a world-load dynamic registry like
Registries.ENCHANTMENT itself and EnchantmentProviderTypes is the dispatch
of kinds inside it (data-driven
types).
EnchantmentsByCost and
EnchantmentsByCostWithDifficulty go through
EnchantmentHelper.selectEnchantment, so a mob’s gear is rolled by exactly
the arithmetic the table uses, with the local difficulty widening the cost; SingleEnchantment skips selection entirely, upgrading one named
enchantment to a sampled level clamped only to that enchantment’s own range,
never asking whether the item supports it. Six of the seven providers
VanillaEnchantmentProviders registers are that third kind.
The loot path, and the trades on it
The loot path runs wherever a loot table does, and villager trades are on
it: a VillagerTrade carries a list of LootItemFunctions applied to what
it gives, and the librarian’s enchanted book is EnchantRandomlyFunction with its only_compatible item check turned off, followed by a filter that discards the trade if the book came out with nothing
stored in it.
EnchantWithLevelsFunction is the cost-based one, in chest loot, fishing and
trades alike, and calls
EnchantmentHelper.enchantItem — the same selection again;
SetEnchantmentsFunction names its enchantments, and vanilla gives it constant
levels. Both random ones can set
DataComponents.ADDITIONAL_TRADE_COST when the context offers
LootContextParams.ADDITIONAL_COST_COMPONENT_ALLOWED, which is how a strong
enchantment makes a trade dearer (loot tables,
contexts and
predicates).
EnchantedCountIncreaseFunction sits in the same package and is the odd one
out: it adds nothing, reading a level off the killer with
EnchantmentHelper.getEnchantmentLevel to add to a drop count. It
consumes this page’s output rather than producing any.
The seventh producer, which is not a path at all
One more producer belongs in nobody’s mental model of enchanting.
CreativeModeTabs builds the creative enchanted books with
EnchantmentHelper.createBook — maximum level only in the tab, every level
in the search, through
CreativeModeTabs.generateEnchantmentBookTypesOnlyMaxLevel and
CreativeModeTabs.generateEnchantmentBookTypesAllLevels.
Where to look
EnchantmentHelper.selectEnchantment is the one method to read whole. It is short, four callers on two of the five paths run it, and every piece of arithmetic the
flowchart above draws is in it, including the halving loop.
EnchantmentHelper.getEnchantmentCost beside it is where the bookshelves land
and EnchantmentHelper.getAvailableEnchantmentResults is the primary-item
filter it calls.
For the table as a machine, read EnchantmentMenu top to bottom:
EnchantmentMenu.slotsChanged computes the offers and
EnchantmentMenu.clickMenuButton runs the same computation again and applies
it, which is the page’s whole point about the clue. EnchantmentScreen is the client’s side, whose copy of the menu can only say no, and EnchantmentNames is the gibberish.
For the other four paths, one class each and they are all small:
AnvilMenu.createResult for the price, GrindstoneMenu for the strip and the
refund, EnchantmentProvider with VanillaEnchantmentProviders for a mob’s
gear, and EnchantCommand for the shortest path of the five.
ItemStack.enchant, EnchantmentHelper.updateEnchantments and the anvil’s
EnchantmentHelper.setEnchantments are the ends the paths meet at, and
Enchantment.areCompatible is the exclusivity test wherever one is asked.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Contexts and predicates
Verified against Minecraft 26.3 · Part VII · A command asks is this true, here, of this entity? — and the machinery that answers is the same machinery that decides what a chest contains.
/execute if predicate example:in_the_rain run say wet is a question about
the world, and the world is not something a data-pack file may look at.
Before anything can be tested, the server has to turn here into something a
JSON file is allowed to interrogate: a position, maybe an entity, nothing
else. It does that by building a small typed map of parameters and
handing it to an object loaded out of a registry, which returns a boolean.
That machinery is usually called the loot system, because most of it lives
in net/minecraft/world/level/storage/loot — but the keys and the sets it
is built from live in net/minecraft/util/context and know nothing about
loot at all. Seventeen of the thirty-one parameter sets never roll a loot
table, and the set that is enforced is always the caller’s: a loot
table’s own declared type is read only by the load-time validator, once a
reload, and never consulted by a roll.
Two packages, one machine
The machine is five classes in two packages, and which class sits in which package is the fact the rest of the page hangs off.
flowchart TD
Callers["block breaks, deaths, chests, commands, selectors, advancements, trades, enchantment effects, fuel, composters"]
Client["SlotDisplayContext, on the client"]:::client
subgraph U["util/context"]
CK["ContextKey, an Identifier plus a static type"]
CKS["ContextKeySet, the required keys and the allowed"]
CMap["ContextMap, the checked bag of values"]
end
LPar["LootParams, the immutable inputs"]:::server
LCtx["LootContext, one invocation"]:::server
CK -->|"declared required or optional by ContextKeySet.Builder"| CKS
CKS -->|"ContextMap.Builder.buildAndValidate validates against it"| CMap
Callers -->|"each brings its own key set"| CKS
Client -->|"builds a ContextMap of its own"| CMap
CMap -->|"wrapped, with a ServerLevel beside it"| LPar
LPar -->|"plus a random source and a resolver"| LCtx
Every arrow leads down toward a checked map or the invocation built on it, and the box is the whole of the general-purpose half. The three classes inside it name no item, block, entity or loot table; the two below it are the loot package, and everything that asks a question is outside on the right.
The dividing line is the whole argument of this page. ContextKey,
ContextKeySet and ContextMap are three small general-purpose classes in
net/minecraft/util/context with no dependency on items, blocks, entities
or loot. Everything below them is in the loot package, and everything
above them is whoever wants a question answered. Whatever rolls a loot table is one such caller. The others are commands, entity selectors, advancement triggers,
villager trades, every conditional enchantment effect, a furnace’s or brewing stand’s fuel
and a composter’s layers — and, on the client, SlotDisplayContext, which
builds a ContextMap of its own so a recipe book entry can resolve itself
into stacks. World generation builds one too, in NoiseChunk, with no key
set at all: it is how the blender and the beardifier reach the density
functions.
The cast
| class | package | what it owns | when |
|---|---|---|---|
ContextKey | util/context | one parameter’s name and its Java type, and nothing else | — |
ContextKeySet | util/context | which keys a call site must supply, and which it may | built once at class-init |
ContextMap | util/context | the values, and the only place the contract is enforced | Server for loot; the Render thread builds its own for a recipe display, and world generation one per NoiseChunk |
LootParams | storage/loot | the ServerLevel, the map, the dynamic-drop callbacks, the luck | Server |
LootContext | storage/loot | one invocation: the random source, the reference resolver, the visited stack | Server |
LootItemCondition | storage/loot/predicates | a predicate over a LootContext — the boolean answer | Server |
ContextFloatProvider | storage/loot/providers | a float over a LootContext — the numeric answer, beside ContextIntProvider for an int | Server |
ValidationContext | storage/loot | at load, whether an element asked for a key its set does not have | the worker pool |
A key is a name with a type welded to it
ContextKey is an Identifier and a phantom type parameter. It has no
value, no default, no validation and no registry — ContextKey.vanilla
just makes one in the minecraft namespace, and only twenty-one exist: the
sixteen static fields of LootContextParams, the one on SlotDisplayContext
that lets a client draw a recipe, and four that world generation declares on
Blender and Beardifier. The type parameter is what
makes the rest of the system safe: LootContextParams.ORIGIN is a key of
Vec3, LootContextParams.TOOL a key of ItemInstance (the read-only
item view), LootContextParams.ENCHANTMENT_LEVEL a key of a boxed int, and
a reader gets that type back without a cast.
Those twenty-one keys are Java, and a data-pack author never types one of
their names. What a JSON file writes instead is a target — this,
attacker, target_entity, tool, block_entity — and LootContextArg is
the small translator between the two, resolving a target to a key out of
three enums nested in LootContext: LootContext.EntityTarget with six
entity keys, LootContext.BlockEntityTarget and
LootContext.ItemStackTarget with one each. One slot source,
RangeSlotSource, adds a target outside them, container, for
LootContextParams.CONTAINER.
All three enums resolve through LootContextArg.SimpleGetter, which reads the map
with LootContext.getOptional, the one way a context hands out a parameter’s value. A target the
current set does not carry therefore evaluates to nothing instead of taking
the tick down — which is the second of the three ways a parameter can be
missing, two sections on.
A set is a contract, and the caller signs it
ContextKeySet holds two sets: ContextKeySet.required and
ContextKeySet.allowed, the second the union of required and optional, and
its ContextKeySet.Builder refuses to make one key both in either order.
LootContextParamSets registers thirty-one of them into the built-in
registry BuiltInRegistries.CONTEXT_KEY_SET, which is what
LootContextParamSets.CODEC reads when a data pack names a set by id — and
the ids do not always match the field names:
LootContextParamSets.PIGLIN_BARTER is barter and
LootContextParamSets.ALL_PARAMS is generic. Every set’s keys are
tabulated in Loot context parameter
sets.
LootContextParamSets.ALL_PARAMS deserves its own warning, because it is
the default a loot table with no declared type gets
(LootTable.DEFAULT_PARAM_SET) and the set every standalone predicate, item
modifier, slot source and number provider file is validated against — and it
is all of them. It requires all sixteen keys, and
LootContextParamSets.validate fails the bootstrap if any other set allows a
key it does not require. The practical consequence: a standalone predicate
file is never flagged at load for a key it reads, so one that asks about a
block state or a damage source loads clean even though
LootContextParamSets.COMMAND — the set /execute if predicate builds —
carries neither.
Three ways a parameter can be missing
A mismatch between what a call site has and what an element wants is caught
at three different moments, with three different costs, and the three are not
alternatives — an element can pass the load-time check and still come back empty at read time. ContextMap.Builder.buildAndValidate is where the contract is enforced,
and it is the only place: it throws if the values collected include a key the set
does not allow, and again if the set requires a key that is absent. Note
what it compares — the keys the caller supplied against the set the
caller named. Nothing on this path ever sees the loot table.
- At build time.
ContextMap.Builder.buildAndValidatethrows, naming the offending keys. That is a programming error rather than a data one, and on a tick it takes the tick down with it (a command catches it and fails). - At read time.
LootContext.getOptionalis the one way a context hands out a parameter’s value (LootContext.hasParameteronly asks whether one is there), and it returns nothing for a missing key, so the conditions and functions degrade quietly rather than fail —LocationCheck,MatchBlockandEnchantmentActiveCheckall answer false. So the standalone-file validator’s silence about a block state is survivable: from/execute if predicatesuch a predicate is quietly false, not fatal. - At load time.
ValidationContext.validateContextUsagecompares what an element declares it reads —LootContextUser.getReferencedContextParams, overridden by twenty-seven classes — againstContextKeySet.allowed, and reports the difference.
The load-time check is the loose one
It checks against allowed, not
required, so an element that reads an optional key passes validation and
can still take path 2 at runtime. And what
ReloadableServerRegistries.validateLootRegistries does with the collected
problems is log them as warnings and fail the reload only on a reference
cycle: a predicate that asks for a parameter it cannot have loads fine and
misbehaves later. The same validation is a
hard error in exactly two places, both applied by a codec —
Validatable.validatorForContext for VillagerTrade, and its list form
Validatable.listValidatorForContext for every conditional effect in
EnchantmentEffectComponents. Neither a trade nor an enchantment effect can
name a predicate by id: both are decoded with the world’s registries, before
the predicate registry is loaded, so RegistryFileCodec rejects the
reference outright.
Inputs, then one invocation
LootParams is the immutable half: a ServerLevel, the ContextMap, a
map of LootParams.DynamicDrop callbacks keyed by Identifier, and a
float of luck. The ServerLevel is the mechanical guarantee that no loot context is built on the client — LootParams.Builder takes one in its
constructor and LootContext.Builder.create dereferences the server off it
for the registries, so a ClientLevel cannot produce a context at all.
LootContext is the per-invocation half, and it adds three things
LootParams does not have: the chosen RandomSource, a
HolderGetter.Provider that resolves references to other loaded elements,
and a set of LootContext.VisitedEntry used as a recursion guard.
LootContext.pushVisitedElement returns false when the element is already
present, which is how LootTable.getRandomItemsRaw refuses a table already
rolling — it logs an infinite loop and rolls nothing. The guard is a stack, not a history: LootContext.popVisitedElement takes the entry off again on
the way out, so rolling the same table twice in one evaluation is fine and
only genuine re-entrancy trips it. Both of the command call sites below —
/execute if predicate and the selector’s predicate option — seed it with
the top-level predicate before testing, but no predicate reads it back: one
that references itself by name never loads, because Validatable.validateHolder
reports the cycle as a fatal ValidationContext.RecursiveElementReferenceProblem.
Named random sequences belong to the context, not to the table.
LootContext.Builder.create takes an optional Identifier and picks a
random source three ways, in order: an explicit source or a non-zero seed
handed to LootContext.Builder.withOptionalRandomSeed, else
MinecraftServer.getRandomSequence for the named sequence, else
Level.getRandom. RandomSequences is a SavedData (level data and
rules) that derives each sequence’s
seed from the world seed, a salt and the sequence id, which is what makes a
named sequence reproducible across restarts. A loot table supplies that
identifier from its own field — and so does TradeSet.randomSequence, read
by AbstractVillager.addOffersFromTradeSet, which is why a villager’s trade
selection is reproducible by the same mechanism and has nothing to do with
loot. What a zero seed in that order means for a chest belongs to loot
tables.
What reads a context
LootContextUser — what did you read out of the map — is extended by five
data-driven types: LootItemCondition, LootItemFunction, NbtProvider,
ScoreboardNameProvider and SlotSource. The number providers read the same
map through two interfaces of their own, ContextFloatProvider and
ContextIntProvider. Each of the seven has its own registry of kinds
(data-driven types),
and three of them carry the traffic. LootItemCondition is a predicate over a
LootContext with twenty types registered in LootItemConditionTypes, from
LootItemEntityPropertyCondition and LocationCheck to
EnchantmentActiveCheck and MatchBlock; ContextFloatProvider returns a
float and has twenty-eight, from ConstantValue and UniformGenerator to
EnchantmentLevelProvider, and ContextIntProvider returns an int and has
twenty-three. Their codecs are forgiving in the same shape:
LootItemCondition.CODEC, ContextFloatProviders.CODEC and
ContextIntProviders.CODEC each take a bare id as a reference to an element
of their own registry, and the two number codecs take a bare number as a
ConstantValue. NbtProvider and ScoreboardNameProvider are the two small ones — a tag out
of an entity, a block entity or command storage, and a scoreboard name, fixed or out of a context target — and LootItemFunction belongs to loot
tables. One of the seven lives outside the loot
package altogether: SlotSource, in net/minecraft/world/item/slot, answers
a SlotCollection rather than a boolean or a number, and its six registered
kinds compose — GroupSlotSource, FilteredSlotSource, RangeSlotSource,
LimitSlotSource, ContentsSlotSource and EmptySlotSource. Its consumers
are the SlotLoot entry, which is how a loot table names a slot of the entity
it is rolling for, and SlotSourceArgument, the slots argument of /item
and /execute if items.
The advancement system tests conditions directly: the player half of every trigger but ImpossibleTrigger,
SimpleCriterionTrigger.SimpleInstance.player, is one
LootItemCondition in a Holder, named by id or written inline, and
EntityPredicate.wrap folds an EntityPredicate into a
LootItemEntityPropertyCondition to make one. The rest of
net/minecraft/advancements/predicates need not touch a context: a trigger
instance can hold an ItemPredicate or a LocationPredicate outright and
test it with no context at all, which ConsumeItemTrigger and
DistanceTrigger both do.
Who asks, and with which set
Twenty-eight callers. The six in bold ask a question with no loot table anywhere in sight, and six more roll no table either; read the six and skim the rest, which are here as the evidence for the count at the end of the section.
| caller | set | when |
|---|---|---|
BlockBehaviour.getDrops | LootContextParamSets.BLOCK | any block break (block breaking) |
Block.dropFromBlockInteractLootTable | LootContextParamSets.BLOCK_INTERACT | beehives, cave vines, carving a pumpkin, sweet berries |
LivingEntity.dropFromLootTable | LootContextParamSets.ENTITY | death (damage and death) |
LivingEntity.dropFromShearingLootTable | LootContextParamSets.SHEARING | sheep, mooshrooms, snow golems, bogged |
LivingEntity.dropFromGiftLootTable | LootContextParamSets.GIFT | hero gifts, cat gifts, chicken eggs, sniffer digs |
LivingEntity.dropFromEntityInteractLootTable | LootContextParamSets.ENTITY_INTERACT | brushing an armadillo |
RandomizableContainer.unpackLootTable | LootContextParamSets.CHEST | first touch of a structure container |
ContainerEntity.unpackChestVehicleLootTable | LootContextParamSets.CHEST | first touch of a chest minecart or chest boat |
FishingHook.retrieve | LootContextParamSets.FISHING | reeling in — luck is the hook’s plus the owner’s |
BrushableBlockEntity | LootContextParamSets.ARCHAEOLOGY | brushing suspicious sand or gravel |
PiglinAi.getBarterResponseItems | LootContextParamSets.PIGLIN_BARTER | bartering |
Mob.createEquipmentParams | LootContextParamSets.EQUIPMENT | the EquipmentUser.equip path, for a spawner’s mob |
VaultBlockEntity | LootContextParamSets.VAULT | a trial chamber vault’s display item and its reward |
TrialSpawner.ejectReward, TrialSpawnerStateData.getDispensingItems | LootContextParamSets.EMPTY | a trial spawner’s reward and its dispensed items |
AdvancementRewards.grant | LootContextParamSets.ADVANCEMENT_REWARD | advancement loot |
Enchantment.damageContext and its four siblings | the four enchanted sets plus LootContextParamSets.HIT_BLOCK | an enchantment effect’s condition |
BaseContainerBlockEntity.getLootContext | LootContextParamSets.CONTAINER_PROCESS | a furnace’s or brewing stand’s fuel |
ComposterBlock.addLayer | LootContextParamSets.BLOCK_INTERACT | how many layers an item adds to a composter |
LootCommand | block, entity, chest or fishing | /loot |
ItemCommands.applyModifier | LootContextParamSets.COMMAND | /item … modify and the from … modifier forms |
ItemCommands.getSlotsFromProvider | LootContextParamSets.COMMAND_SLOT_SOURCE | the slots of /item and /execute if items |
LootContextSources | the three command_compute sets | /compute and /data modify … compute |
ExecuteCommand | LootContextParamSets.COMMAND | /execute if predicate |
EntitySelectorOptions | LootContextParamSets.SELECTOR | a selector’s predicate argument |
EntityPredicate.createContext | LootContextParamSets.ADVANCEMENT_ENTITY | every advancement trigger that tests an entity |
ItemUsedOnLocationTrigger, AnyBlockInteractionTrigger | LootContextParamSets.ADVANCEMENT_LOCATION | placing or using an item on a block |
DefaultBlockInteractionTrigger | LootContextParamSets.BLOCK_USE | right-clicking a block, for an advancement |
AbstractVillager.addOffersFromTradeSet | LootContextParamSets.VILLAGER_TRADE | rolling and filtering a villager’s offers |
Fourteen roll a table; seventeen never do
The bold rows have no loot table anywhere in sight: a boolean, or in the
villager’s case a MerchantOffers, is the whole output.
ValidationContextSource even keeps a cached
LootContextParamSets.ADVANCEMENT_ENTITY context around, because so much
of the advancement tree validates against it.
Count sets rather than rows and the picture is starker. Fourteen of the
thirty-one are named above by a caller that goes on to roll a LootTable.
The other seventeen never do. Six are the bold rows —
LootContextParamSets.COMMAND counts among them, because its other caller,
/item’s modifier forms, applies an item modifier rather than a table. Five are the
enchantment sets, built only by Enchantment to decide whether an effect
fires. Five more are built for a number or a set of slots: the three
command_compute sets, LootContextParamSets.COMMAND_SLOT_SOURCE and
LootContextParamSets.CONTAINER_PROCESS. The seventeenth is
LootContextParamSets.ALL_PARAMS, which is never used to build a
ContextMap at all: it exists solely as a validation
context and as LootTable.DEFAULT_PARAM_SET. That is why this page is not
called loot tables.
Four facts about a question with two keys in it
/execute if predicate is a short complete use of the machine: one
command, one ContextMap, one predicate, and no loot table anywhere in it.
sequenceDiagram
participant ExecC as ExecuteCommand
participant LootP as LootParams
participant CMap as ContextMap
participant LootC as LootContext
participant LIC as LootItemCondition
Note over ExecC: the argument already holds a Holder, resolved at parse time
ExecC->>LootP: LootParams.Builder on the level, ORIGIN required, THIS_ENTITY optional
LootP->>CMap: ContextMap.Builder.<br/>buildAndValidate against LootContextParamSets.COMMAND
CMap-->>LootP: the checked map, or a throw on a bad key
ExecC->>LootC: LootContext.Builder.create with no random sequence
Note over LootC: the random source is Level.getRandom, the resolver is the reloadable registries
ExecC->>LootC: pushVisitedElement, seeding the recursion guard with this predicate
ExecC->>LIC: test
LIC->>LootC: getOptional, the one way to a parameter, null for a missing key
LIC-->>ExecC: a boolean, and the branch is taken or not
The whole machine, once, with no loot in it. Read the two dashed returns — the first is the only place the contract is enforced, and the last is the only thing the caller gets back.
Three details in that picture are worth pulling out, and a fourth about the call site it does not draw.
The predicate is resolved before the command runs. The argument type is
ResourceOrIdArgument.LootPredicateArgument, which accepts either a
registry id or an inline SNBT object and hands back a Holder — a
reference in the first case, a direct holder over a freshly parsed
condition in the second. An unknown id fails at parse time, as a command
syntax error, not at execution.
LootContextParamSets.COMMAND is thin. It requires
LootContextParams.ORIGIN and optionally allows
LootContextParams.THIS_ENTITY, and that is all: a predicate run from
/execute cannot see a block state, a damage source or a tool, whatever
the standalone-file validator let through. The selector’s version,
LootContextParamSets.SELECTOR, differs in exactly one way — it makes the
entity required, because a selector always has one.
There is no random sequence. ExecuteCommand passes an empty optional,
so Level.getRandom is what a LootItemRandomChanceCondition inside the
predicate draws from — not correlated between runs, not reproducible across
restarts.
The selector path is the same code, and it is not in the diagram.
EntitySelectorOptions builds its context per candidate entity, inside
the predicate it hands the parser, and looks the condition up itself
through the reloadable registries rather than through the argument type —
so a missing predicate there is a silent false, not a syntax error.
Six registries, rebuilt on /reload, and none of them networkable
LootDataType is the six loot registries expressed as data:
LootDataType.TABLE over Registries.LOOT_TABLE,
LootDataType.PREDICATE over Registries.PREDICATE,
LootDataType.MODIFIER over Registries.ITEM_MODIFIER,
LootDataType.SLOT_SOURCE over Registries.SLOT_SOURCE, and
LootDataType.FLOAT_PROVIDER and LootDataType.INT_PROVIDER over
Registries.CONTEXT_FLOAT_PROVIDER and Registries.CONTEXT_INT_PROVIDER.
Each pairs a registry key with a LootDataType.ContextGetter — the function
that says which ContextKeySet an element of that type is validated
against. Every type but tables gets the constant
LootContextParamSets.ALL_PARAMS; tables get LootTable.getParamSet, and that
context getter is its only caller in the game. That is the sense in which a
table’s declared type is never checked at runtime.
Reloadable, and never networkable
All six live in RegistryLayer.RELOADABLE and are rebuilt by every reload
(the resource
system),
which is what makes them the reachable half of the data-driven game: a
predicate a pack changed is live after /reload. One step of that rebuild matters here — a predicate names its tags against the
ones this reload has just read, so a tag a pack added in the same reload
resolves, though what it holds is bound only when the reload is applied. None
of the six appears in RegistryDataLoader.SYNCHRONIZED_REGISTRIES, the
list RegistrySynchronization.isNetworkable tests, so none is ever packed
for a client. What crosses the wire is only the result — a container
packet, an item entity, a command’s success.
Both dependants outside this part are Part XIII’s: its commands — this page
owns /execute if predicate itself, and what Part XIII owns is the engine it
runs in and the selector
argument whose predicate option is the
other caller — and the advancement
system, whose triggers build a context per tested entity or interaction.
For a 1.21-era reader. LootContextParam is
ContextKeyand LootContextParamSet isContextKeySet, both moved out of the loot package tonet/minecraft/util/context. The predicate library that consumes them has left the misspelled critereon package fornet/minecraft/advancements/predicatesandnet/minecraft/advancements/triggers. NumberProvider is nowContextFloatProviderandContextIntProvider, and ConditionReference and ContextAwarePredicate are gone: aHolderdoes their work. The drift table has the rest.
Where to look
Three files, in this order, are the whole argument: ContextKey,
ContextKeySet and ContextMap, in net/minecraft/util/context. They are
short, they import nothing from the loot package, and reading them is the
fastest way to see that the loot system’s foundation is not about loot.
Then LootContextParams and LootContextParamSets for the sixteen loot keys
and the thirty-one sets — the second is one long list of builders and repays
skimming rather than reading. LootParams and LootContext above them are
the two halves of one invocation, and LootContext.Builder.create is where
the random source is chosen three ways.
LootItemCondition is the interface with twenty implementations; open two of
them, LocationCheck for the ordinary shape and InvertedLootItemCondition
for one that holds another in a Holder, and then Validatable.validateHolder
for the load-time cycle check in use. ValidationContext.validateContextUsage is the
load-time check, and LootDataType is where it is told which set to check
against.
For the callers, ExecuteCommand and EntitySelectorOptions are two small complete examples of building a context by hand.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Loot tables
Verified against Minecraft 26.3 · Part VII · A player opens a dungeon chest for the first time, and every item in it comes into existence between the click and the screen.
You break into a mossy cobblestone room, there is a chest against the wall,
and you right-click it. Between that click and the inventory screen the server
does something it will do exactly once for this chest: it looks up a loot
table, rolls it, scatters the results across the empty slots, and throws the
recipe away. Until that moment the chest is genuinely empty on disk — the
region file holds a table key, a seed where the chest was given one, and no
items at all. And the roll is a
one-shot in the strictest sense: RandomizableContainer.unpackLootTable clears
the stored key before it rolls, and the thing that triggers it is not “a
player opens this chest” but “anything reads this container”. A hopper
underneath taking one item, or a comparator behind the wall asking how full it
is, will commit the roll with no player present, for good — and in vanilla
that costs nothing but a bastion chest’s advancement, since no chest table reads
your luck.
The typed parameters the table is handed, the predicates it tests and the sets
those belong to are not loot machinery — they are the general context engine
that enchantment effects, advancement triggers, /execute if predicate and
villager trade filters all run on, and
contexts and
predicates
is where they are explained. Loot is that engine’s largest client, fourteen of its thirty-one parameter sets,
and one consequence of borrowing an engine is worth knowing before you start:
a table’s own declared type is read once at load, by the validator, and
never compared against the parameters it is actually handed. Whether a chest
table works is entirely down to what the caller put in the map. This page is
the worked example: what a table is, how one draw picks an item, and why the
chest was empty.
The cast
| class | what it decides | thread |
|---|---|---|
LootTable | the parameter set, an optional random sequence, the pools, the table’s own modifier, and the ways out — LootTable.fill into a container, LootTable.getRandomItems into a list, and the unsplit LootTable.getRandomItemsRaw a nested table uses | Server |
LootPool | whether the pool runs at all, and how many draws it makes | Server |
LootPoolEntryContainer | the entry algebra — ComposableEntryContainer.expand answers did I contribute | Server |
UniformContainerBase | weight and quality — one of the two places luck reaches anything | Server |
LootItemFunction | forty-two registered kinds, all conditional, one optional modifier per tier — several functions make one SequenceFunction | Server |
RandomizableContainer | the stored table key and seed, and the one-shot unpack | Server |
LootContext | which random source this draw uses, and the recursion guard | Server |
ReloadableServerRegistries | loading the eight reloadable registries, and validating the six loot ones among them | the worker pool |
The chest was empty before you got there
Nothing wrote items into that chest at world generation. MonsterRoomFeature
places the room, then places up to two chests, giving each up to three tries
at a position — every try needing an air block with exactly one solid
horizontal neighbour — and for each chest that lands calls
RandomizableContainer.setBlockEntityLootTable with
BuiltInLootTables.SIMPLE_DUNGEON and a fresh long from the feature’s own
random. What lands on the live ChestBlockEntity is a key and a seed, and
nothing else. Structure pieces do the same through StructurePiece.createChest
and StructurePiece.createDispenser. BuiltInLootTables holds a hundred and
twenty-one named keys and two per-dye-colour families for sheep — one under
entities and one under shearing — spread over fifteen directories that
are worth a glance for the range of things that roll a table at all: not only
chests and dispensers but brushing, shearing, harvesting, carving, decorated
pots, mob equipment, spawners and a charged creeper’s.
The seed reaches disk on the next save, and from then on the emptiness is
self-perpetuating. RandomizableContainer.trySaveLootTable writes the key —
and the seed only when it is non-zero — and answers yes, I handled this, so
ChestBlockEntity.saveAdditional never writes an item list.
RandomizableContainer.tryLoadLootTable answers the same way on the way back
in, and ChestBlockEntity.loadAdditional skips reading items entirely. A
chest nobody has touched has no inventory in any file the game has ever
written.
What counts as reading it
The opening said anything reads this container, and the rule is exactly that
literal. RandomizableContainerBlockEntity overrides five of the container
methods so that each unpacks before answering:
RandomizableContainerBlockEntity.isEmpty,
RandomizableContainerBlockEntity.getItem,
RandomizableContainerBlockEntity.removeItem,
RandomizableContainerBlockEntity.removeItemNoUpdate and — the surprising one
— RandomizableContainerBlockEntity.setItem. Writing into an unrolled chest rolls it first, and the write then replaces
whatever the roll put in that slot.
Every way a chest is rolled without its player goes through one of those five,
and every one of them unpacks with a null player, so the roll gets no
Attributes.LUCK and fires no CriteriaTriggers.GENERATE_LOOT:
- A comparator reaches
Container.getItemover every slot throughAbstractContainerMenu.getRedstoneSignalFromContainer(what a comparator can see). - A hopper underneath asks the same question before it takes anything — and so does a hopper pointing into the chest, which checks whether the destination is full before it pushes. Both directions commit the roll by reading.
- Breaking the block does it too, and this is the one players do not
expect:
BlockEntity.preRemoveSideEffectsdrops a container’s contents by walkingContainer.getItemover every slot, so mining an untouched dungeon chest rolls it and scatters the result on the ground, with no player in the roll.
What does not unpack is as precise. Clearable.clearContent and
Container.getContainerSize do not, and neither does saving — which is why
/data get block on an unopened chest reports the loot table key instead of
committing the roll, and why a structure that has never been visited costs
nothing to keep.
From the click to the screen
Right-clicking a dungeon chest is one call that does four things in an order worth watching: it resolves a provider, commits the roll, builds a menu, and only then tells the client anything.
sequenceDiagram
participant SPGM as ServerPlayer<br/>GameMode
participant SP as ServerPlayer
participant CBE as ChestBlockEntity
participant LT as LootTable
participant ChestM as ChestMenu
participant Wire as the network
Note over SPGM,CBE: ChestBlock.useWithoutItem picks the provider, and a single chest is its own
SPGM->>SP: openMenu, handed the block entity
SP->>CBE: createMenu, which opens by asking canOpen
CBE->>CBE: unpackLootTable, inside createMenu, before a menu exists
Note over CBE,LT: RandomizableContainer.<br/>setLootTable to null <br/>BEFORE the roll
CBE->>LT: fill, on the CHEST set, with the stored seed
LT->>LT: getRandomItemsRaw, every pool, inside createStackSplitter
LT->>LT: getAvailableSlots, then shuffleAndSplitItems across them
LT->>CBE: setItem, once per surviving stack
CBE->>ChestM: threeRows over the now-filled container
SP->>Wire: ClientboundOpenScreenPacket, and only then the contents
SP->>ChestM: setSynchronizer, from inside ServerPlayer.initMenu
ChestM->>Wire: one ClientboundContainer<br/>SetContentPacket,<br/>from sendAllDataToRemote
The roll is three messages deep inside
RandomizableContainerBlockEntity.createMenu, and the screen is the last three arrows. Look at where the chest is filled relative to where
the client is told anything — the contents exist before the screen is asked
for.
The click arrives as ServerPlayerGameMode.useItemOn and reaches
ChestBlock.useWithoutItem
(block interaction), which asks
ChestBlock.getMenuProvider for something to open. A single chest is its own
provider — the combiner hands back the ChestBlockEntity. A double chest is the
interesting case: it gets an anonymous provider wrapped round a
CompoundContainer that requires both halves to pass
RandomizableContainerBlockEntity.canOpen, unpacks both loot tables
itself, and never enters either block entity’s own menu factory.
Opening goes through ServerPlayer.openMenu, which closes whatever menu was
already open, allocates a container id, and calls
RandomizableContainerBlockEntity.createMenu. Both gates on opening are in one
predicate: RandomizableContainerBlockEntity.canOpen adds a spectator clause to
the lock check it inherits from BaseContainerBlockEntity.canOpen, and the
spectator half bites only while a table is still pending — so a spectator
cannot commit the roll by peering into an unopened chest, though they can open
one that has already been rolled.
The unpack is RandomizableContainer.unpackLootTable, and its order matters
more than anything else on this page. It looks the key up through
ReloadableServerRegistries.Holder.getLootTable — which answers
LootTable.EMPTY for a missing key, never null — fires
CriteriaTriggers.GENERATE_LOOT if a ServerPlayer is doing the opening, and
then clears the stored key with RandomizableContainer.setLootTable,
before a single die is rolled. It builds the
parameters with LootContextParams.ORIGIN at the block centre and, only if a
player is present, that player’s Player.getLuck — the live value of
Attributes.LUCK, whose default is zero and which nothing but a potion or a
command moves (attributes) — and
LootContextParams.THIS_ENTITY. Then it calls LootTable.fill with the
container, those parameters and the stored seed.
The screen comes last and is no part of the roll. The menu itself is a
ChestMenu, built by ChestMenu.threeRows over the container the roll has
just filled. ServerPlayer.openMenu
sends ClientboundOpenScreenPacket and then calls ServerPlayer.initMenu,
which attaches the listener and the synchronizer;
AbstractContainerMenu.setSynchronizer ends in
AbstractContainerMenu.sendAllDataToRemote, and that is the single
ClientboundContainerSetContentPacket carrying the freshly rolled contents
(containers and
menus).
One roll, drawn
LootTable.fill runs every pool of the table, each pool makes some number of
independent draws, and each draw picks at most one entry. That draw is the
engine’s smallest complete unit, and it narrows the whole way down — from
however many entries a pool declares, through the expansion where composites
become candidates, to at most one candidate.
flowchart TD
A["LootPool.addRandomItems"] --> B{"the pool condition, if there is one"}
B -->|"it fails"| Z["the pool contributes nothing"]
B -->|"it passes, or none"| C["draws: rolls plus floor of LootPool.bonusRolls times luck"]
C --> D["one draw, repeated that many times"]
D --> E["expand every entry container, in declaration order, into candidates"]
E --> K["weight: floor of weight plus quality times luck, clamped at zero"]
K --> L{"is that above zero?"}
L -->|"no"| M["dropped from this draw entirely"]
L -->|"yes"| N["kept, and added to the running total"]
N --> O{"how many candidates survived?"}
O -->|"none, or the total is zero"| Z2["this draw yields nothing"]
O -->|"exactly one"| P["taken, with no roll to pick it"]
O -->|"two or more"| Q["one RandomSource.nextInt over the total, then walk subtracting weights"]
A pool’s draws, each narrowing to at most one candidate. Note the middle arm out of the last diamond, where a draw with one surviving candidate is taken without a roll to pick it.
The one box that hides a fan-out is expand, and what it expands are the five containers:
| the container | what expanding it yields |
|---|---|
AlternativesEntry | an or: stops at the first child that contributes |
SequentialEntry | an and: stops at the first child that does not |
EntryGroup | every child expands, and the contribution is ignored |
TagEntry in expand mode | one candidate per item in the tag |
NestedLootTable | one candidate that runs every table in its set, or in expand mode one per table |
What becomes of the stack that survives — the tiers of modifiers, the splitter, the shuffle and the write — is the rest of this section, the trace above and the scatter below; it is a straight line and needs no second picture.
The algebra is boolean, not weighted. ComposableEntryContainer.expand
returns a plain did I contribute, and each composite folds its children into
one of those in its own CompositeEntryBase.compose — the two-child case
literally calling ComposableEntryContainer.or or
ComposableEntryContainer.and, and the longer cases a hand-written loop with
the same short-circuit. So AlternativesEntry behaves like a boolean or and
SequentialEntry like a boolean and — neither is a weighted choice between
branches, and the validator warns about an AlternativesEntry whose non-final children
carry no conditions, since a leaf with none always contributes and hides every
alternative after it. Nine entry types are registered in LootPoolEntries — one more registry of
kinds in a page made of them (data-driven
types) — and the
thing each one produces is a LootPoolEntry, the candidate the funnel above
weighs. They are the leaves
LootItem, EmptyLootItem, TagEntry, NestedLootTable, DynamicLoot and
SlotLoot, and the composites AlternativesEntry, SequentialEntry and
EntryGroup. TagEntry has two modes and they are not variations on a theme:
expanded, it becomes one weighted candidate per item in the tag, each
carrying the entry’s own modifier; unexpanded, it is a single candidate that
emits every item in the tag at once, the modifier running on each.
Luck touches exactly two things, and neither is what players think it is.
LootPool.bonusRolls is multiplied by luck and floored to add whole extra
draws, and UniformContainerBase.EntryBase.getWeight is
weight + quality × luck, floored, then clamped at zero. Because a candidate
whose weight comes out at zero or below is discarded rather than merely made
rare, a negative quality with enough luck removes an entry from the pool
altogether. No vanilla chest table uses either: quality appears only in the
fishing table, where Luck of the Sea feeds the hook’s luck. Everything else a player calls luck on a drop is a loot function or condition
reading an enchantment level off the tool or off the killer, which is why Fortune
has no effect component at all
and why Looting’s one, a higher chance of dropping the mob’s equipment, is not
what adds to its loot.
Functions apply innermost first. Nothing on this path returns a stack:
a draw is handed a consumer to push its results into, and each tier wraps that consumer before passing it down, with LootItemFunction.decorate over
its one optional modifier, several functions at one tier being a single
SequenceFunction. So a drop passes the entry’s modifier first, then any enclosing composite’s, then
the pool’s, then the table’s.
All forty-two registered functions, SequenceFunction among them, extend
LootItemConditionalFunction, whose LootItemConditionalFunction.apply is
final and hands the stack back untouched when its own condition fails — which
is why a function with a failing condition is a no-op and not a veto on the
drop. Most also mutate the stack in place and return it
(SetItemFunction, SmeltItemFunction, DiscardItem, SetRandomDyesFunction,
and the three enchanting functions handed a plain book, return a different one),
which is safe because every leaf hands out something of
its own: LootItem and TagEntry construct fresh stacks and SlotLoot emits
copies. DynamicLoot is the exception: it calls straight out to
a callback the caller registered with LootParams.Builder.withDynamicDrop, and the one ShulkerBoxBlock.getDrops registers hands back the block entity’s
live stacks uncopied, though no vanilla table names it; the one that does, the
decorated pot’s sherds, builds fresh stacks.
The scatter
LootTable.fill does not simply place what it rolled.
LootTable.getAvailableSlots collects the container’s empty slot numbers and
shuffles them; LootTable.shuffleAndSplitItems then pulls the multi-count
stacks out of the result list and repeatedly splits one — taking a random amount
between one and half its count — until the number of pieces roughly matches the
slot count. That is why one rolled stack of arrows arrives as several partial
ones in unrelated slots — how many depends on how much of the chest is free. If the pieces outnumber the free slots, the remainder
is logged as a warning and silently discarded.
Above that sits LootTable.createStackSplitter, which every public
LootTable.getRandomItems and LootTable.fill wraps its output in: it drops
items the level’s feature flags disable, and cuts anything at or over its
maximum stack size into stack-sized pieces. NestedLootTable deliberately calls
LootTable.getRandomItemsRaw instead, so a nested table’s results are split
once by the outer table rather than twice. Nesting the same table twice is
fine, from two pools or two rolls of one: the recursion guard is a stack that pops on the way out rather than a list of
everything seen (contexts and
predicates), so only a
table genuinely inside itself would trip it, and such a table fails the whole
reload: validation reports the cycle as fatal.
Where the randomness comes from
A LootContext picks its random source by trying three things in order — an
explicitly supplied source or seed, then the server’s saved sequence for a
named random sequence, then the plain Level.getRandom (contexts and
predicates owns the
mechanism). For a chest the load-bearing part is the condition on the first of
the three: a seed is installed only when it is non-zero, the zero
LootTable.RANDOMIZE_SEED names.
So a seed of zero means unseeded, is indistinguishable from having no seed at all, and is never written to NBT — which is why a chest given a loot table by command rolls whatever its table’s named sequence is up to (every vanilla table names one; a table that names none falls to the level’s random), where a structure chest, carrying a seed, rolls the same contents whoever opens it and whenever. Neither rolls twice: the key is gone after the first unpack either way. A table that declares a named random sequence draws from a stored, saved one instead of the level random, which is what keeps the same table in the same world reproducible across a restart even when no chest ever carried a seed.
And which thread does the rolling
One, everywhere. Loading is the only part of any of this that is not on the Server thread — one RegistryLoadTask per reloadable registry
on the worker pool, with the layer frozen before anything is validated (the resource
system).
Rolling is on the Server thread without exception, and the guarantee is a type rather
than a thread check: a LootParams demands a ServerLevel to be built at all
(contexts and
predicates). The
strongest form of it is a fact about the whole tree: no client class
references the loot package at all.
A loot table that travels inside an item
Everything above is a table anchored to a position. One kind is not.
DataComponents.CONTAINER_LOOT carries a SeededContainerLoot — a key and a
seed living in a stack’s component patch — which a container broken with no player to roll it (an explosion, a piston)
writes into the stack it drops when its own loot table copies the component, as
the shulker boxes’ do (RandomizableContainerBlockEntity.collectImplicitComponents;
creative pick-block writes it too), and which the loot function
SetContainerLootTable writes directly, as against SetContainerContents, which
writes the stacks themselves. So a shulker box given a loot table and blown up
unopened comes out already owing a roll, and carries that debt around in your
inventory.
The component is persistent with no network codec of its own, which under the
rule on data
components means
one is derived for it, so it does reach the client. What it cannot carry
is the contents: the client has no loot registry at all, and
SeededContainerLoot.addToTooltip does not try — it prints the
unknown-contents line and nothing else, on either side. That tooltip is the
only place in the game where a player is shown, in so many words, that the
items do not exist yet.
For a 1.21-era reader. LootPoolSingletonContainer is gone:
UniformContainerBaseholds weight and quality, withSingleEntryContainerBaseandExpandableContainerBasebeneath it. Each list of functions is one optional modifier, several functions making oneSequenceFunction, so LootItemFunctions.compose is gone too, and each list of conditions is one optional condition.
Where to look
RandomizableContainer.unpackLootTable is one short method and holds the whole of
why the chest was empty, including the order of the clear and the roll. Read
RandomizableContainerBlockEntity straight after it for the five overrides
that decide what counts as reading.
Then the roll, outside in: LootTable.fill, LootPool.addRandomItems, and
UniformContainerBase.EntryBase.getWeight for the two lines where luck
touches anything. ComposableEntryContainer is the boolean algebra, and
AlternativesEntry beside TagEntry is the shortest way to see that a
composite and a leaf are the same interface.
LootItemConditionalFunction is all forty-two functions, and
its final LootItemConditionalFunction.apply is why a failing condition is a no-op rather than a veto.
LootTable.createStackSplitter and LootTable.shuffleAndSplitItems are the
scatter.
Three callers on the other side of the door are worth opening to see the range of
what rolls a table: BlockBehaviour.BlockStateBase.getDrops for every block
broken (block breaking),
LivingEntity.dropFromLootTable for every mob killed (damage and
death), and EquipmentUser.equip, the one caller that compares the looked-up table
against LootTable.EMPTY by identity to decide whether to bother.
EnchantWithLevelsFunction, a function a table runs rather than a caller, is the
cost-based function on the loot side of
enchanting.
Contexts and predicates
has the thirty-one sets they build against, and the count of which of them roll
a table.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
VIII · The player
Verified against Minecraft 26.3 · Part VIII · The entity a human plays as: what it is made of, when it runs, and the five things it does that the rest of the world does differently.
Everything in Parts IV to VII happens to the world. This part is about the one object in it that argues back. You meet it as friction — the snap back after a laggy jump, the swing that does more damage if you wait, the food bar that empties from sprinting and not from walking, the effect timer that keeps counting while the connection is down — and every one of those is the same thing showing through. A player is an entity like any other, with the same base class, the same tick and the same synched data, and then almost every rule is bent for it: it is ticked twice instead of once, its inventory reports seven slots it does not store, its melee combat has three separate entry points of which the famous one is the least interesting, and the server simulates its movement in full every tick and then throws the answer away. The server is not allowed to be right about the player — nor about anything a player steers, which borrows the player’s authority — and this part is the cost of that.
The shape of the part
Part VIII is a trunk and four branches. Two pages say what a player is and when it runs; everything after them is one thing a player does, and they are independent of each other — except the spear, which is the sword swing’s sequel and should not be watched before it. The inventory has no lecture of its own and does not need one: Part VII stops at the slot, and the container those slots sit in is a section of player anatomy, because what is interesting about it is that seven of its forty-three slots are somewhere else. So the shape is a trunk of two pages, four branches that do not depend on one another, and one sequel — which is what the figure numbers.
flowchart TD
PA["1 · Player anatomy — eight classes, forty-three slots"]
TT["2 · The two-phase tick — when it runs, and what is thrown away"]
IM["3 · Input to movement — walking, and being believed"]
SS["4 · The sword swing — one integer, and a number rebuilt"]
SP["5 · The spear — two more melee paths"]
HE["6 · Hunger and experience — two bars the server owns"]
SE["7 · Status effects — a list of things happening to you"]
PA --> TT
TT --> IM
TT --> SS
TT --> HE
TT --> SE
SS -- "its sequel" --> SP
The trunk and its four branches, numbered to the watch order: an arrow means the page below assumes the page above, and the four leaving the two-phase tick are independent of one another, so the labelled edge is the only order this part insists on beyond the trunk.
Before you start
Part VI is the hard prerequisite, and two of its
pages in particular. Entity
anatomy,
because a player is a LivingEntity with three rungs added on the server
and four on the client, and this part never re-teaches the base; and
authority,
because every page here rests on it — a Player is client-authoritative on
both sides, and the server’s own answer for your movement is thrown away in
favour of the number you sent it. If you watch one page from
another part first, watch that one. Three more of Part VI’s are assumed
rather than re-taught: attributes,
because the reach and base numbers of an ordinary swing are attributes; synched entity
data;
and movement and collision,
the pipeline input to movement feeds and never repeats.
Then the server tick
and the level tick,
because half this part’s timing claims are about which phase something ran in
— including the fact that a player’s own physics run after every level has
finished. Players and
sessions owns
how a ServerPlayer comes to exist at all, and how the session it belongs to
ends. From Part VII, using an
item, because the
spear’s charge is an item you use and the meal in hunger and experience
is that same machinery from the eater’s end.
Watch in this order
- Player anatomy — the vocabulary page: eight
classes, two game-mode objects, forty-three slots. There is a rung between
LivingEntityandPlayer—Avatar— with no instance state at all, shared with a posable dummy; and the main-hand item has no storage of its own, being the selected hotbar slot seen through the equipment container a horse also has. - The two-phase tick — one player, one tick, twice. The connection records where you are, runs the whole physics pipeline, and then puts you back: the server keeps the velocity and throws the position away.
- Input to movement — W is pressed. A held movement key pressed and released between two ticks never happened, flooding the server with move packets makes the anti-cheat stricter, and the packet that reports your key presses cannot move you but can move a minecart.
- The sword swing — left-click on a pig. The attack packet carries one integer and the server rebuilds the rest, charging the hit against a cooldown twice in two different shapes — quadratically on the base damage, linearly on the enchantment bonus — and multiplying the mace’s fall bonus by the critical hit.
- The spear — the part’s most surprising lecture. Two components on one item: a stab whose packet has no target in it, and a charge whose damage comes from closing speed and which ignores the attack cooldown entirely.
- Hunger and experience — two bars the server owns outright. Walking costs exactly zero exhaustion; a full bar with saturation left heals up to eight times faster than one at eighteen; and the experience packet watches the total, so every mutation that moves only your level has to poison the last-sent value by hand.
- Status effects — the part’s closer, and its cleanest statement of the server/client split: the client runs none of an effect’s behaviour — it counts the effect down, draws it, and reads it for its own movement. The server corrects that countdown by testing whether the remaining duration divides by six hundred, so the periodic correction never reaches an infinite effect, whose duration is −1.
Watched as lectures, one and two are the pair to keep together, and four and five are the other pair. Six and seven can be watched in either order, or skipped and returned to.
Where the part stops
Part VIII is the smallest part of the book with a system in it (only Part I
is smaller, and Part I is two pages about the program the rest run inside
and what the book leaves out) —
32 files and 8,460 lines in world/entity/player,
world/food, ServerPlayer and client/player — and the coverage question
has an almost trivial answer here:
1% of the part’s lines are named on no page in the book. A player is a small object
surrounded by large ones, so the interesting borders are not the ones inside
these packages but the ones just outside them.
Upward, the part starts at Avatar rather than at Entity: Avatar sits
in Part VI’s package and is explained here, beside the Mannequin — the
posable dummy that shares the rung, and the reason the rung exists — and what
a player inherits above it belongs to Part VI.
Outward, it stops where the player stops being a player. How a hit is
resolved once it lands is damage and
death in Part
VI; what your client is told about everyone else is what the client is
told
in Part IX; the ledger behind the block you already saw break is prediction
and acknowledgement in
Part X; drawing a player, its skin and its model parts is Part XI’s; and the chat session a ServerPlayer carries — the public half of message signing,
the key itself never leaving the client — belongs to chat and
signing. The
two game-mode objects, ServerPlayerGameMode and MultiPlayerGameMode, are
the sharpest border of all: this part says what they hold, and what they
do with a block is Part V’s two click pages.
Three things a reader will look for here have no lecture. Two are declined: Hotbar, and the
HotbarManager just outside these packages that keeps the nine saved
creative hotbars, belong to the creative screen’s saved hotbars, which this
book does not cover; and sleep is
half-explained on purpose, with player
anatomy naming the fields and the
refusals a bed answers with while the everyone is asleep half — the night
skip and the weather reset — belongs to the level
tick.
The third is simply missing, and is the one gap this part admits rather than
declines: nobody explains the walk between those two halves — what one player
lying down does over the hundred ticks of Player.SLEEP_DURATION
that follow, and which of the two owners is driving during them.
Reference this part uses
Attributes, because reach, attack damage,
attack speed, sweeping ratio and knockback are all attributes — and attack
damage and knockback, two of those that decide what a hit is worth, are not
synced to the client at all. Packets for the
movement, attack and health packets by name. Data
components for the components that make an
item a weapon. Damage outside
LivingEntity for what a swing meets
when the target is not a living entity. Then game
rules, level data and
rules and diagram
lanes.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Player anatomy
Verified against Minecraft 26.3 · Part VIII · You open your own inventory and look at what you are made of: six classes deep on your own screen, forty-three slots wide, and the item in your hand has no slot of its own at all.
You are a LivingEntity that a human is steering down a connection. Almost
everything on this page follows from that sentence: the class ladder exists
to separate what any living thing does from what a thing with an
inventory and a game mode does from what a thing with a connection does.
Six rungs of it reach from Entity down to the object on your own screen,
and five from Entity down to the one the server holds. But two of the things a
reader goes looking for are not where they look. One rung of that ladder
holds no instance state at all, and the main-hand item has no storage of
its own — the hotbar slot you are looking at and the item
LivingEntity.getMainHandItem returns are the same bytes, aliased through
the equipment container a horse also has.
The cast
| class | what it decides | thread |
|---|---|---|
Avatar | the player-shaped hitbox, the eye height and the two cosmetic synched values — and no instance state | both |
Player | everything a reader means by the player: inventory, abilities, experience, sleep, reach | both |
ServerPlayer | the connection, the advancements, the statistics, and the server’s last sent fields | Server |
AbstractClientPlayer | the rung both client players share: the tab-list entry, the skin, the animation state | Render |
LocalPlayer / RemotePlayer | the two that sit on it: the one a human steers, and every other player on your screen — interpolated, never derived | Render |
Inventory | thirty-six stacks, and a window onto EntityEquipment for the rest | both |
ServerPlayerGameMode / MultiPlayerGameMode | what the current GameType allows, one object per side | Server / Render |
Mannequin | the other Avatar: a posable dummy that gets the whole skin pipeline | both |
The ladder, and the class in the middle
classDiagram
class Entity
<<abstract>> Entity
class LivingEntity
<<abstract>> LivingEntity
class Avatar {
<<abstract>>
no instance fields at all
}
class Player
<<abstract>> Player
class AbstractClientPlayer
<<abstract>> AbstractClientPlayer
Entity <|-- LivingEntity
LivingEntity <|-- Avatar
Avatar <|-- Player
Avatar <|-- Mannequin
Player <|-- ServerPlayer
Player <|-- AbstractClientPlayer
AbstractClientPlayer <|-- LocalPlayer
AbstractClientPlayer <|-- RemotePlayer
Mannequin <|-- ClientMannequin
Every abstract rung is marked, so the five unmarked classes are the only
ones the game instantiates outside its test framework — and the branch beside
Player is the reason Avatar exists at all.
Entity and LivingEntity belong to Part
VI.
The rung below them, Avatar (world/entity), is short and has no
instance fields at all — every name below it is a static
constant or a static EntityDataAccessor, a key onto the synched table
Entity owns, so owning a synched value here means holding the key and
not the storage. It owns the player-shaped dimensions
(Avatar.POSES, Avatar.STANDING_DIMENSIONS, Avatar.CROUCH_BB_HEIGHT,
Avatar.SWIMMING_BB_WIDTH, Avatar.SWIMMING_BB_HEIGHT), the 1.62 eye
height (Avatar.DEFAULT_EYE_HEIGHT), the two cosmetic synched values
(Avatar.DATA_PLAYER_MAIN_HAND, Avatar.DATA_PLAYER_MODE_CUSTOMISATION)
read back through Avatar.getMainArm and Avatar.isModelPartShown, and
one abstract method, Avatar.getProfile, returning a ResolvableProfile.
That, a setter and a few defaults aside, is the whole class.
It exists because two things in the game are shaped like a person and only
one of them is a player. Avatar ships in the server jar, and what it holds
is what Player and Mannequin both need and nothing else does: the
body, the eye height, the arm you favour and the model parts you have turned
off. Everything a player has on top of that — the inventory, the abilities,
the hunger, the attribute set — is Player’s, which is why
Player.createAttributes is on Player and a mannequin gets the plain
living set (attributes).
The renderer follows the same line rather than drawing it: AvatarRenderer
is generic over an Avatar that is also a ClientAvatarEntity, and exactly
two classes satisfy that, AbstractClientPlayer and ClientMannequin.
That pairing is worth knowing, because the mannequin repeats Player’s
server/client split on its own branch, and in one class instead of two:
Mannequin (world/entity/decoration) holds a mutable static factory,
Mannequin.constructor, and ClientMannequin.registerOverrides replaces it
during client startup, so a mannequin spawned into a ClientLevel is really
a ClientMannequin with the full PlayerModel and skin pipeline behind it.
The mannequin is therefore a sibling of Player, not of ArmorStand;
only the package is shared with the armour stand. It is posable
(Mannequin.VALID_POSES), profiled (Mannequin.DATA_PROFILE), describable
(Mannequin.DATA_DESCRIPTION) and optionally immovable
(Mannequin.DATA_IMMOVABLE).
Player itself is abstract for one method above all: Player.gameMode,
returning a nullable GameType. A long tail of its other methods are empty
hooks that exist so the two sides can disagree —
Player.onUpdateAbilities, Player.awardStat,
Player.triggerRecipeCrafted, Player.crit, Player.magicCrit,
Player.sendSystemMessage, Player.doCloseContainer,
Player.openTextEdit, Player.sendMerchantOffers,
Player.handleCreativeModeItemDrop. On Player they do nothing;
ServerPlayer, which has somewhere to send a packet, overrides all but the
last, and LocalPlayer overrides six, the last among them.
What Player owns
| what | the fields | who explains it |
|---|---|---|
| storage | Player.inventory, Player.enderChestInventory (a PlayerEnderChestContainer) | below |
| the open window | Player.inventoryMenu (final) and Player.containerMenu, which is Player.inventoryMenu when nothing is open | containers and menus |
| what the mode allows | Player.abilities | below |
| the food bar | Player.foodData | hunger and experience |
| experience | Player.experienceLevel, Player.experienceProgress, Player.totalExperience, Player.enchantmentSeed, Player.lastLevelUpTime, Player.takeXpDelay | hunger and experience |
| sleep | Player.sleepCounter, Player.startSleepInBed / Player.stopSleepInBed, the Player.BedSleepingProblem refusals, Player.SLEEP_DURATION (100) and Player.WAKE_UP_DURATION (10) | ServerLevel owns the everyone is asleep half |
| the two attack clocks | LivingEntity.attackStrengthTicker and LivingEntity.itemSwapTicker, declared two rungs up but read, reset and incremented only here | the sword swing |
| cooldowns | Player.cooldowns, built by Player.createItemCooldowns, which only ServerPlayer overrides | using an item |
| four synched values | Player.DATA_PLAYER_ABSORPTION_ID, Player.DATA_SCORE_ID, Player.DATA_SHOULDER_PARROT_LEFT, Player.DATA_SHOULDER_PARROT_RIGHT | synched entity data |
| addressing | Player.ENDER_SLOT_OFFSET (200), Player.HELD_ITEM_SLOT (499), Player.CRAFTING_SLOT_OFFSET (500), decoded by Player.getSlot — the numbering a command’s slot argument speaks | below |
| the odds and ends | Player.gameProfile, Player.lastDeathLocation, Player.fishing, Player.reducedDebugInfo, Player.lastItemInMainHand, Player.hurtDir, Player.jumpTriggerTime, Player.wasUnderwater | — |
None of those four synched values is the hand, which is Avatar’s;
and a player’s skin is not synched data at all: it arrives out of band,
from the tab-list entry. A mannequin’s does travel as synched data, in
Mannequin.DATA_PROFILE.
The skin itself is a small closed family in the same package, and it is
worth naming because a player’s skin is not on the entity at all and the
toggles are one byte there. PlayerSkin is a
record of three textures — body, cape and elytra — plus a PlayerModelType
(PlayerModelType.SLIM or PlayerModelType.WIDE, the two arm widths) and a
secure flag saying whether the textures came signed. It lives on the
tab-list entry, not on the player, which is why a skin can change without an
entity packet. PlayerModelPart is the other half: the seven toggles in
the skin-customisation screen, each a bit in the byte
Avatar.DATA_PLAYER_MODE_CUSTOMISATION carries, read back through
Avatar.isModelPartShown. Drawing any of it is Part XI’s — the skin and
the seven toggles land in an AvatarRenderState at extract
time.
Reach is two attributes, not one. Player.blockInteractionRange and
Player.entityInteractionRange read Attributes.BLOCK_INTERACTION_RANGE
and Attributes.ENTITY_INTERACTION_RANGE
(attributes), whose defaults — 4.5 and 3.0 —
live on the attributes themselves. Player.DEFAULT_BLOCK_INTERACTION_RANGE
and Player.DEFAULT_ENTITY_INTERACTION_RANGE name the same two numbers. The
checks the server makes are
Player.isWithinBlockInteractionRange and
Player.isWithinEntityInteractionRange. Note which class supplies which:
Player.createAttributes adds attack damage, attack speed, luck, a player’s
own movement speed, the block range, Attributes.BLOCK_BREAK_SPEED,
Attributes.SUBMERGED_MINING_SPEED, Attributes.SNEAKING_SPEED,
Attributes.MINING_EFFICIENCY, Attributes.SWEEPING_DAMAGE_RATIO and the
waypoint attributes, while the entity range comes from
LivingEntity.createLivingAttributes.
Two smaller seams: Player.permissions returns
PermissionSet.NO_PERMISSIONS and both sides override it — it is what
Player.canUseGameMasterBlocks consults alongside Abilities.instabuild
for Permissions.COMMANDS_GAMEMASTER — and Player implements
ContainerUser, which is how a chest counts who still has it open, over a
box Player.getContainerInteractionRange sizes.
Forty-three slots, and one of them is an alias
Inventory implements Container in a particular shape: one
Inventory.items list of thirty-six stacks (Inventory.INVENTORY_SIZE)
plus a reference to the player’s EntityEquipment. Slots at or above
thirty-six are not stored here at all. Inventory.EQUIPMENT_SLOT_MAPPING
routes them into the equipment object — the four armour indices,
Inventory.SLOT_OFFHAND (40), Inventory.SLOT_BODY_ARMOR (41) and
Inventory.SLOT_SADDLE (42) — so Inventory.getContainerSize is
forty-three, and a player carries the same body-armour and saddle slots
a horse does.
Player.createEquipment returns a PlayerEquipment, which overrides
the map so that EquipmentSlot.MAINHAND resolves to
Inventory.getSelectedItem. That is the alias: the held item is not stored
twice, and the main hand is the selected hotbar slot seen through the
equipment interface.
The rest of the class is the vocabulary the whole game uses to put things
in a player: Inventory.add, Inventory.getFreeSlot,
Inventory.getSlotWithRemainingSpace,
Inventory.placeItemBackInInventory, Inventory.findSlotMatchingItem,
Inventory.contains, Inventory.removeItem,
Inventory.clearOrCountMatchingItems, Inventory.dropAll,
Inventory.getSuitableHotbarSlot, Inventory.addAndPickItem and
Inventory.pickSlot (pick-block), and Inventory.fillStackedContents, which
hands the thirty-six ordinary slots to a StackedItemContents, which keeps
the stacks a recipe may draw on and tallies them in StackedContents — the
matcher that lives in this package and belongs to recipes. Inventory.save and Inventory.load cover the thirty-six —
the equipment half is persisted by LivingEntity — and
Inventory.setSelectedSlot throws rather than accept a non-hotbar index.
Why the forty-three need two ticking calls
The split is visible once a tick. Inventory.tick runs over the thirty-six
ordinary slots, called from Player.aiStep; EntityEquipment.tick covers
the other seven, called from LivingEntity.aiStep two rungs up. Each walks
only its own storage, though the two reach into each other — Inventory
routes the last seven indices into the equipment, and PlayerEquipment
answers the main hand from the inventory. A third
walk then crosses the whole forty-three at once — the second half of the
player’s tick offers every stack to
ServerPlayer.synchronizeSpecialItemUpdates, which is how a filled map gets
its update packet (the two-phase
tick).
Two callers for one tick hook is not an accident of history; it is the
storage split showing through.
The other numbering, the one a command speaks
Those forty-three are not the numbering a command speaks. Player.getSlot
decodes a second, sparser addressing over the same storage and past it: an
index inside the thirty-six is an ordinary slot, Player.ENDER_SLOT_OFFSET
plus n reaches the ender chest, Player.CRAFTING_SLOT_OFFSET plus n the
four crafting-grid slots, Player.HELD_ITEM_SLOT is the stack on the cursor
rather than any stored slot, and anything else falls through to the equipment
slots two rungs up. That is the map behind a slot argument in a command.
Abilities, GameType, and the one method that connects them
Abilities is five public booleans and two floats:
Abilities.invulnerable, Abilities.flying, Abilities.mayfly,
Abilities.instabuild, Abilities.mayBuild, plus
Abilities.getFlyingSpeed and Abilities.getWalkingSpeed. It does not
serialise itself by hand — it packs into the record Abilities.Packed and
Abilities.Packed.CODEC does the work, through Abilities.pack and
Abilities.apply.
GameType is the four-constant enum (GameType.SURVIVAL,
GameType.CREATIVE, GameType.ADVENTURE, GameType.SPECTATOR) with
GameType.DEFAULT_MODE, a GameType.CODEC and a GameType.STREAM_CODEC.
The method that matters is GameType.updatePlayerAbilities: the single
place in the game that decides which abilities a mode grants. Both sides
call it — the server from ServerPlayerGameMode, the client from
MultiPlayerGameMode on login, on respawn and on a mode-change event.
GameType.isBlockPlacingRestricted is what sets Abilities.mayBuild, and
GameType.isSurvival is true for GameType.ADVENTURE too.
The save file does not use the accessors’ names for any of this:
Abilities.Packed.CODEC names the keys, and it writes flySpeed where the
accessor is Abilities.getFlyingSpeed. While reading that class, note the
misspelled constant beside it — Abilities.DEFAULY_FLYING.
That is also where a data pack’s reach over the player ends, before it
begins. The attribute defaults Player.createAttributes supplies are code,
and the starting GameType comes from the world’s settings and the server’s
properties; the ladder, the slot count and what each mode grants are code too,
which makes the player one of the few systems in the game a data pack cannot
redefine.
The two game-mode objects
ServerPlayerGameMode (server/level) | MultiPlayerGameMode (client/multiplayer) | |
|---|---|---|
| owns the mode | ServerPlayerGameMode.getGameModeForPlayer | MultiPlayerGameMode.getPlayerMode |
| changes it | ServerPlayerGameMode.changeGameModeForPlayer | MultiPlayerGameMode.setLocalMode |
| breaking state | ServerPlayerGameMode.isDestroyingBlock, ServerPlayerGameMode.destroyProgressStart, ServerPlayerGameMode.hasDelayedDestroy | MultiPlayerGameMode.isDestroying, MultiPlayerGameMode.destroyProgress, MultiPlayerGameMode.destroyDelay |
| the block hooks | ServerPlayerGameMode.handleBlockBreakAction, ServerPlayerGameMode.useItemOn, ServerPlayerGameMode.useItem | MultiPlayerGameMode.startDestroyBlock, MultiPlayerGameMode.continueDestroyBlock, MultiPlayerGameMode.useItemOn, MultiPlayerGameMode.useItem |
| attacking | — (ServerGamePacketListenerImpl handles it) | MultiPlayerGameMode.attack, MultiPlayerGameMode.interact |
| containers | — | MultiPlayerGameMode.handleContainerInput |
ServerPlayerGameMode has exactly one subclass, DemoMode, which
MinecraftServer hands a player instead when the server is in demo mode. It
watches the level’s own gameTime for the demo’s days, keeping counters of
its own only for the intro screen and to space out the reminders: it fires
the tutorial prompts on the first morning, a message on each of the
DemoMode.DEMO_DAYS, and past DemoMode.TOTAL_PLAY_TICKS it overrides the
block-break and use hooks to answer with a reminder instead of doing
anything. It is the only place in the game where the kind of game-mode
object, rather than the GameType, decides what you may do.
Neither object is held by Player itself, and only one of them is held by
a player at all: Minecraft.gameMode holds the client one — LocalPlayer
has no such field — while ServerPlayer.gameMode is a field on the server
player. Block
interaction
and block breaking
own the block halves of both columns,
and prediction and
acknowledgement owns
the ledger they share — which, note, MultiPlayerGameMode does not hold
either: MultiPlayerGameMode.startPrediction reaches for
ClientLevel.getBlockStatePredictionHandler per call.
The three sides of one player
Three concrete classes carry a player, and the fourth name in this section is
not a fourth side: AbstractClientPlayer is the rung the two client ones
share, and it holds what both of them need.
ServerPlayer is everything that needs a server. ServerPlayer.connection
is the ServerGamePacketListenerImpl everything else reaches the client
through; ServerPlayer.advancements, ServerPlayer.stats and
ServerPlayer.recipeBook (a ServerRecipeBook) are the three ledgers a
session accumulates; ServerPlayer.chunkTrackingView and
ServerPlayer.lastSectionPos record what the client has been sent (what the
client is
told);
and ServerPlayer.chatSession, a RemoteChatSession, holds the public
half of message signing, since the key pair never leaves the client (chat and
signing).
Around those sit the fields that exist because a server has to remember
something the world cannot re-derive: ServerPlayer.respawnConfig for where
you come back, ServerPlayer.camera for what you are looking through,
ServerPlayer.textFilter for what a chat message is allowed to be,
ServerPlayer.wardenSpawnTracker for how loud you have been,
ServerPlayer.enderPearls for the pearls in flight, and
ServerPlayer.containerSynchronizer for the open window’s own outbound
channel.
What is worth more than the roll-call is the shape of two rows of fields on
it, because both exist for the same reason and answer to different readers.
ServerPlayer.lastSentHealth, ServerPlayer.lastSentFood,
ServerPlayer.lastFoodSaturationZero and ServerPlayer.lastSentExp turn a
difference into a packet — the first three into one, experience into
another; a second row, from
ServerPlayer.lastRecordedHealthAndAbsorption through
ServerPlayer.lastRecordedArmor and on, turns a difference into a
scoreboard criterion update instead. Nothing on the server watches a
value change: it compares the value against the copy it last acted on. A
third pair remembers what the client said rather than what the server
found — ServerPlayer.lastClientInput (an Input) and
ServerPlayer.lastKnownClientMovement, both explained by input to
movement. Players and
sessions owns
this object’s lifecycle; it is constructed during the configuration phase
by PrepareSpawnTask, before the play listener exists.
AbstractClientPlayer adds what a client needs to draw a player and to
name one. Its AbstractClientPlayer.playerInfo is the tab-list entry,
fetched lazily from the connection — and that entry is enough of a directory
that the client registers LocalPlayerResolver in front of the ordinary
profile lookup, so a profile the client needs for a skin, a mannequin’s or a
head’s, resolves out of the tab list before anything asks a server. It also carries the per-frame animation state
AvatarRenderer reads (AbstractClientPlayer.clientAvatarState),
AbstractClientPlayer.getSkin and the field-of-view modifier.
LocalPlayer is the one the human steers, and everything it adds is
about sending: LocalPlayer.connection (a ClientPacketListener),
LocalPlayer.input (a ClientInput at construction, swapped for a
KeyboardInput by the connection on login and respawn),
LocalPlayer.lastSentInput and the last-sent position block, and
LocalPlayer.dropSpamThrottler, which is the client’s own rate limit on
throwing things away. The rest is what only a first-person view needs:
LocalPlayer.recipeBook (a ClientRecipeBook), LocalPlayer.permissions,
LocalPlayer.startedUsingItem, the auto-jump pair, and
LocalPlayer.yBob and LocalPlayer.xBob, which lag the view so the
first-person hand sways behind it.
RemotePlayer is every other player on the client, and
adds almost nothing: it sets Entity.noPhysics when it is built (each
Player.tick then sets it to whether the player is a spectator), interpolates through
RemotePlayer.lerpDeltaMovement, and has an empty
RemotePlayer.updatePlayerPose — another player’s pose is told to you, not
derived.
Which of the three is allowed to decide anything is Part VI’s
authority,
stated once there: a Player is client-authoritative on both sides, and
the server runs the physics anyway. The two-phase
tick is what that
costs.
What crosses between them
Very little of a player is entity-synched, so the packets that carry one are
their own small set. ClientboundLoginPacket and ClientboundRespawnPacket
both carry a CommonPlayerSpawnInfo built by
ServerPlayer.createCommonSpawnInfo — which is also where the client’s
local game mode comes from. A mode change afterwards arrives as
ClientboundGameEventPacket.CHANGE_GAME_MODE or
ClientboundPlayerInfoUpdatePacket.Action.UPDATE_GAME_MODE, and the
abilities as ClientboundPlayerAbilitiesPacket. The hotbar selection travels
both ways, as ClientboundSetHeldSlotPacket and
ServerboundSetCarriedItemPacket, and the inventory itself through the open
menu’s sync — with ClientboundSetPlayerInventoryPacket, built by
Inventory.createInventoryUpdatePacket, for each slot
Inventory.placeItemBackInInventory fills.
What a player is on disk
Player.addAdditionalSaveData and Player.readAdditionalSaveData are where
a player becomes a file: the inventory as a sparse slot/stack list, the
selected slot, the sleep timer, the four experience fields including the
enchanting seed, the food data, the score, the abilities through
Abilities.Packed, the ender chest, and the last death location. ServerPlayer adds the game-type
history through ServerPlayer.storeGameTypes, the thrown ender pearls
through ServerPlayer.saveEnderPearls, the vehicle through
ServerPlayer.saveParentVehicle, and ServerPlayer.SavedPosition — which
is read before the entity exists, by the configuration-phase spawn task.
Questions players ask
Why does the creative flag not come from my abilities?
Player.isCreative and Player.isSpectator do not read Abilities at all;
they compare Player.gameMode against GameType constants. The flags
themselves are reached through accessors on Player that consult Abilities
for you, and the widest by far is Player.hasInfiniteMaterials, which reads
Abilities.instabuild and has about twice the call sites of the next,
Player.canUseGameMasterBlocks, which reads the same flag — with
Player.preventsBlockDrops, Player.mayBuild and Player.isPushedByFluid
well behind. The most
permissive flag in the game is also the most widely consulted one.
Whose game mode arrives late — and why is it yours?
On the client every player’s mode comes from the tab list:
AbstractClientPlayer.gameMode resolves through
AbstractClientPlayer.getPlayerInfo, and returns null when there is no
entry. For another player that gap cannot be observed —
ClientPacketListener refuses to spawn a RemotePlayer whose PlayerInfo
has not arrived, and a PlayerInfo starts at GameType.DEFAULT_MODE, so
their mode is survival until an update packet says otherwise, never null.
The one player who really does have a null tab-list entry is you:
LocalPlayer is built during login, before your own entry is sent. What
covers the gap is a second, independent source —
MultiPlayerGameMode.localPlayerMode, set from the spawn info on login and
respawn and from the game-event packet — and that is the one that drives
block breaking and Abilities.mayBuild, the one ability no packet carries.
Why does building permission survive a packet that says otherwise?
Abilities.mayBuild never goes on the wire, and nothing recomputes it on
receipt. ClientboundPlayerAbilitiesPacket carries four flag bits and two
floats, none of them the build permission; the client’s copy is written only
by MultiPlayerGameMode, on login, respawn and a mode change, so an abilities packet with no
mode change leaves it as it was. The other direction is smaller still:
ServerboundPlayerAbilitiesPacket carries only the flying bit.
Where to look
Open Player first and read it as the class the two sides argue over:
Player.getSlot is the one method that shows the command numbering,
Player.createAttributes the one that shows what a player has that a
mannequin does not. Then Avatar above it, which takes two minutes
and explains the Mannequin beside it. Inventory and
PlayerEquipment are the pair to read together, in that order, because
the second is three short methods and is the whole of the alias.
ServerPlayer and LocalPlayer are the two big faces: read them
for their last sent fields rather than end to end. For the modes,
GameType.updatePlayerAbilities is the single decision point and
ServerPlayerGameMode / MultiPlayerGameMode are the two objects
that call it. Two doors this page only points at: PrepareSpawnTask,
where a ServerPlayer is built before the play listener exists, and
AvatarRenderer with its ClientAvatarEntity bound, which is where the
skin and the seven toggles go.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
The two-phase tick
Verified against Minecraft 26.3 · Part VIII · One server tick of one player — which happens twice, from two different callers, and the second half throws its own answer away.
Every other entity on the server is ticked once, by the level it stands in.
A player is ticked twice: once from the level’s entity loop, and once from
its own connection, after every level in the game has finished. Keep the
inheritance chain in view for the whole page, because the halves are told
apart by how far up it they reach: a ServerPlayer is a Player, which is
an Avatar, which is a LivingEntity, which is an Entity, and every one of
them but Avatar declares its own tick. The first half never reaches
Player.tick: what the level runs besides ServerPlayer.tick is
Entity.commonTick, the bookkeeping it gives every entity first, and for a
rider the chain of rideTick overrides that ends in the Entity.rideTick
calling it — Player.rideTick among them, which dismounts a sneaking rider
instead. The one block the two halves have in common is the
container-validity check — and the second half is stranger still. The connection records where the
player is, runs the entire physics pipeline, and then puts the player back
where it found them. The server simulates your movement in full, every
tick, and deletes the result: what it keeps is the velocity, and that is
the number the anti-cheat allows for when it checks your reported motion.
The cast
| class | what it decides | thread |
|---|---|---|
ServerLevel | phase one: ticks the player in entity order, inside the level tick | Server |
ServerPlayer | both halves — ServerPlayer.tick and ServerPlayer.doTick overlap in one block only | Server |
ServerGamePacketListenerImpl | phase two: the record–simulate–snap-back bracket | Server |
Player | Player.tick and Player.aiStep — on the server, reached from phase two only; on the client, from LocalPlayer.tick (and Player.tick from RemotePlayer.tick) | both |
Inventory | the thirty-six ordinary slots’ per-tick item hook | both |
FoodData | hunger, regeneration and starvation, ticked after the physics | Server |
AbstractContainerMenu | the open window, diffed against what the client was told | Server |
LocalPlayer | the client’s own single tick, gated on the level having loaded | Render |
Phase one: what the world does to this player
ServerPlayer.tick is called by the level’s entity loop — through
ServerLevel.tickNonPassenger when the player is walking, and through
ServerLevel.tickPassenger and Entity.rideTick when mounted, each time
after Entity.commonTick has counted down Entity.invulnerableTime, saved
last tick’s position and advanced the tick count — and players
are ticked there whether or not their chunk is entity-ticking (the level
tick). It runs late in ServerLevel.tick:
after the block ticks and the chunk source, before the block entities.
It does not call Player.tick. What it does instead is the outside
world’s business with the player: ServerPlayerGameMode.tick for
block-breaking progress and the delayed destroy (block
breaking), the
countdown of LivingEntity.damageCooldownTime, the hurt cooldown,
AbstractContainerMenu.broadcastChanges on the
open menu followed by closing it if it is no longer valid (containers and
menus),
snapping the player to the camera entity when one is set, the per-tick advancement
criteria and a flush of the dirty ones, the warden spawn tracker, and
ServerPlayer.updatePlayerAttributes. It is not quite connection-free: its
very first statement is the connection’s client-load timeout (players and
sessions).
Phase two: what this player would do if it simulated itself
ServerPlayer.doTick is called by
ServerGamePacketListenerImpl.tickPlayer, from the connection tick, after
every level has ticked. This is the half that calls up into Player.tick
and LivingEntity.tick, so the player’s physics are simulated here. It
then ticks FoodData.tick, the play-time statistics,
ServerPlayer.synchronizeSpecialItemUpdates over all forty-three slots
(player anatomy),
and every has this changed since I last sent it comparison that produces
ClientboundSetHealthPacket and ClientboundSetExperiencePacket (hunger
and experience). This is also where
the shared block turns up: ServerPlayer.doTick re-runs the same
AbstractContainerMenu.stillValid check phase one ran, and closes the menu
if it now fails — the only work both halves do.
The first group of those is gated, and the gate is a single condition:
a spectator that is touching an unloaded chunk skips it. Behind that gate
sit Player.tick, the container check, FoodData.tick and the play-time
statistics; the forty-three-slot sweep and every last-sent comparison run
regardless. So a spectator drifting out of the loaded world stops being
simulated and keeps being reported on.
Player.aiStep, reached from inside that, is where the two item-tick calls
happen and in which order: Inventory.tick over the thirty-six ordinary
slots, before EntityEquipment.tick covers the other seven from
LivingEntity.aiStep — why there are two of them is the forty-three slots
(player anatomy). It is also the
item and orb pickup sweep, gated on being alive and not a spectator; what the
sweep does with an orb once it touches one is hunger and
experience.
The division is clean enough to use as a rule while reading the rest of this part. If the thing you are looking for is the world acting on the player — the menu’s change broadcast, the breaking timer, the spectator camera, the advancement criteria — it is in phase one. If it is the player acting — physics, hunger, effects, item ticking, the health, food and experience packets — it is in phase two.
Both halves, in the order they run
One object, one tick, two callers and two bands — and the one call that appears in both of them is the container check.
sequenceDiagram
participant SL as ServerLevel
participant SP as ServerPlayer
participant SPGM as ServerPlayer<br/>GameMode
participant ACM as Abstract<br/>ContainerMenu
participant SGPL as ServerGamePacket<br/>ListenerImpl
participant FD as FoodData
rect rgba(0, 0, 0, 0.04)
Note over SL,FD: phase 1 — the entity loop, inside the level tick
SL->>SP: commonTick, then tick — never up to Player.tick
SP->>SPGM: tick — the breaking timer and the delayed destroy
SP->>ACM: broadcastChanges, then stillValid
SP->>SP: updatePlayerAttributes — the reach and waypoint modifiers
end
rect rgba(0, 0, 0, 0.04)
Note over SL,FD: phase 2 — the connection tick, after every level
SGPL->>SGPL: resetPosition — this position becomes firstGood
SGPL->>SP: doTick, inside the bracket
SP->>SP: Player.tick, then LivingEntity.tick — the physics
SP->>ACM: stillValid — the only work both halves do
SP->>FD: tick — hunger, regeneration, starvation
SP->>SGPL: ClientboundSetHealthPacket, if a watched field differs
SGPL->>SP: absSnapTo — back to firstGood, keeping the rotation
end
The two bands are the two callers, and ServerPlayer is one lane in both
because it is one object: the physics the page is about happen inside the
second band and their position is undone by its last arrow, and
AbstractContainerMenu is the only other lane both bands touch.
The bracket, and what survives it
ServerGamePacketListenerImpl.tickPlayer is a bracket around one call.
ServerGamePacketListenerImpl.resetPosition records the player’s current
position into the firstGood… and lastGood… fields — good meaning this
is the last place the server was willing to agree you were — then
ServerPlayer.doTick runs, and then the player is snapped back to the
recorded position with Entity.absSnapTo, keeping only the rotation. What runs inside it is the whole of LivingEntity.aiStep,
LivingEntity.travel and Entity.move. The rest of the method is the
anti-cheat that rides along in the same bracket: the floating too long
kick, and the same record-and-check done again for the vehicle the player is
steering. On foot the authoritative position moves in
ServerGamePacketListenerImpl.handleMovePlayer or in a teleport, never here.
Riding is one exception, and it is phase one that makes it one:
Entity.rideTick ticks the passenger and then has the vehicle reposition it,
so a rider’s real position is written by the level’s loop before the bracket
ever opens, and the snap-back at the end of the bracket puts it back there. A
spectator looking through another entity is the other, for the same reason:
phase one snaps them to it.
What survives the snap-back is Entity.getDeltaMovement — the velocity the
anti-cheat allows for when it measures the client’s reported displacement (input to
movement) — plus everything non-positional the tick
did: drowning, burning, effects, hunger, the last-sent diffs. Both halves
run every tick whether or not a packet arrived, and packets are drained
before either of them. What phase two tells the client about its own player
leaves at once: Connection.tick flushes the channel right after it has run
the listener that called
ServerPlayer.doTick (the server
tick).
Not all of a player’s state is written inside the two halves: the handlers
drained before the tick write it too, on the server thread — the position
above is one — and the chat and command handlers write one field of it off
that thread altogether.
Almost every game handler defers to the owning thread before it touches
anything (the server
tick
owns the rule and counts the exceptions); the chat handlers are among the ten
that do not. ServerGamePacketListenerImpl.tryHandleChat reads
ServerPlayer.getChatVisibility and calls ServerPlayer.resetLastActionTime
on the Netty thread, before it hands the rest over (chat and
signing) — one field on a ServerPlayer
read and another written outside both halves and outside the server thread.
The pairing that makes the bracket necessary is Part VI’s
authority:
Player.isClientAuthoritative is an
unconditional yes on both sides, which denies a ServerPlayer
local-instance authority, while Player’s move-simulation type and its
Entity.isEffectiveAi override make Entity.canSimulateMovement and
Entity.isEffectiveAi true on the server anyway. So the
pipeline runs and its answer is not believed.
The client ticks its player once
LocalPlayer.tick runs from ClientLevel’s entity tick on the Render thread,
with its entire body gated on the connection reporting that the level has
loaded. Minecraft.gameMode is ticked separately, and earlier in
Minecraft.tick than the entity tick. ClientInput.tick is called from
inside LocalPlayer.aiStep, so input is sampled inside the tick, not
pushed from the key callback — though the method doing the sampling is
KeyboardInput.tick; ClientInput.tick itself is empty.
Questions players ask
If the server ticks my player from the connection, does a silent client
stop being ticked? No. ServerPlayer.doTick runs every tick regardless of
traffic, and so does ServerPlayer.tick. Only two things hold phase two
back, and neither is silence: MinecraftServer.isPaused — which only an
integrated server ever reports true — short-circuits the whole bracket before
ServerPlayer.doTick is called at all, and the spectator gate above skips
its first half. What stops a silent client moving is neither: it is the
snap-back, which undoes every position the simulation produced.
Why does fall damage come from the packet handler? Because the branch
inside Entity.move is the one gate there that reads local-instance
authority alone, which a ServerPlayer fails (authority).
Entity.doCheckFallDamage on the movement-packet path does it instead, with
the client’s own reported delta — which is the two-phase split showing up as
a damage number.
Is a mounted player different? In phase one, where Entity.rideTick
ticks the player and then has the vehicle place it; and in the bracket, which
skips the player’s floating check and, for a player steering, runs the
vehicle’s record-and-check instead. The movement packets a passenger sends are
judged by an almost entirely separate piece of code too — see input to
movement.
Where to look
Read the two halves side by side: ServerPlayer.tick and
ServerPlayer.doTick are both short, and the second is the one with the
spectator gate at the top of it. Then
ServerGamePacketListenerImpl.tickPlayer for the bracket itself — the
record, the call, the Entity.absSnapTo, and the two floating checks that
ride along after it. Player.aiStep is where the item ticks and the
pickup sweep happen, and LocalPlayer.tick is the client’s whole
one-pass answer to all of it, worth reading immediately afterwards for the
contrast. If you want the callers rather than the callees,
ServerLevel.tickNonPassenger is phase one’s.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Input to movement
Verified against Minecraft 26.3 · Part VIII · W is pressed: the key becomes a boolean, the boolean becomes a velocity, the velocity becomes a packet, and the server decides whether to believe it.
You press W. Nothing happens for up to a twentieth of a second, because the key was not pushed anywhere — it was written into a boolean that the next tick will read. That tick turns seven booleans into a velocity, the velocity into a position, and the position into a packet; the server then decides whether to believe the packet. It usually does. The player, and whatever it steers, is what the server does not control, and everything here exists to reconcile that.
The surprising part is what deciding costs you. Flooding the server with move packets makes the check stricter, not looser, a held key pressed and released between two ticks never happened at all, and the packet that reports your key presses cannot move you — though it can move a minecart. The server has an expected velocity to compare yours against because it simulates your player every tick and throws the answer away (the two-phase tick).
The cast
| class | what it decides | thread |
|---|---|---|
KeyboardHandler | that a key went down, when nothing is in the way of it | Render |
KeyMapping | what that key is bound to, and whether it is held | Render |
KeyboardInput | seven booleans and a normalised vector, once per tick | Render |
LocalPlayer | the movement itself, and what is worth sending | Render |
ServerGamePacketListenerImpl | whether to believe it, and where the player really is | Server |
ServerPlayer | what the client last said: the input, and the known movement | Server |
Who is allowed to decide any of this is Part VI’s authority, and this page assumes it in one sentence: the server simulates your player and is not allowed to believe the result. Everything below is what that costs, and the cost is paid twice — once on the way out, where the client decides, and once on the way in, where the server checks.
What each side holds
On the client
KeyMapping— one object per bindable action, holdingKeyMapping.isDownandKeyMapping.clickCount. Only the first of those matters here: the movement keys are polled withKeyMapping.isDownand never drained withKeyMapping.consumeClick, so a key held for ten ticks reads down ten times. What a mapping is, how a press reaches one, what a toggle does to it and why a sneak toggle comes back when the inventory closes, and a held sneak only if its key is still down (and not on macOS) all belong to input and keybinds.Options— the movement bindings:Options.keyUp,Options.keyDown,Options.keyLeft,Options.keyRight,Options.keyJump,Options.keyShift,Options.keySprint. PlusOptions.autoJumpandOptions.sprintWindow(the double-tap window in ticks, default seven, zero to disable).Input(world/entity/player) — a shared record of seven booleans (forward, backward, left, right, jump, shift, sprint) withInput.EMPTYand anInput.STREAM_CODECthat packs all seven into one byte; the bit values are namedInput.FLAG_FORWARD…Input.FLAG_SPRINT.ClientInput—ClientInput.keyPresses(anInput) plusClientInput.moveVector(aVec2, whereVec2.xis the left impulse andVec2.ythe forward one).ClientInput.makeJumpis how auto-jump fakes a press.ClientInput.tickis empty; the subclass that actually reads the keyboard isKeyboardInput, whoseKeyboardInput.tickbuilds a freshInputfrom the sevenKeyMapping.isDownvalues, maps each pair to −1, 0 or +1 withKeyboardInput.calculateImpulse, and normalises the resulting vector.LocalPlayer— one block of fields whose entire job is what did I last tell the server. Five of them shadow the position and rotation (LocalPlayer.xLast,LocalPlayer.yLast,LocalPlayer.zLast,LocalPlayer.yRotLast,LocalPlayer.xRotLast), two shadow the ground and collision flags (LocalPlayer.lastOnGround,LocalPlayer.lastHorizontalCollision), one shadows the key set (LocalPlayer.lastSentInput), one the sprint state (LocalPlayer.wasSprinting), and one counts up to a re-send that happens whether anything changed or not (LocalPlayer.positionReminder, againstLocalPlayer.POSITION_REMINDER_INTERVAL, twenty). The rest of the block is local state nobody is told about:LocalPlayer.sprintTriggerTimefor the double-tap,LocalPlayer.autoJumpEnabledandLocalPlayer.autoJumpTimefor auto-jump, andLocalPlayer.crouching.
LocalPlayer.input starts as a bare ClientInput and is replaced with a
KeyboardInput by ClientPacketListener on login and on respawn — which
is why a respawned player’s input object is a different one.
On the server
ServerGamePacketListenerImpl holds the whole judgement in four groups of
fields, and the groups are the argument. Two positions, not one:
ServerGamePacketListenerImpl.firstGoodX and its siblings are where the
connection tick found the player at the top of the bracket, and
ServerGamePacketListenerImpl.lastGoodX and its siblings are the last position it accepted from you. Three teleport fields —
ServerGamePacketListenerImpl.awaitingTeleport,
ServerGamePacketListenerImpl.awaitingTeleportTime and
ServerGamePacketListenerImpl.awaitingPositionFromClient — hold the
handshake open while a rubber-band is in flight. Two counters,
ServerGamePacketListenerImpl.receivedMovePacketCount against
ServerGamePacketListenerImpl.knownMovePacketCount, are how the speed budget
learns how many packets arrived since the last tick, with
ServerGamePacketListenerImpl.receivedMovementThisTick and
ServerGamePacketListenerImpl.receivedPositionThisTick the flags that last
one client tick.
And a floating pair, ServerGamePacketListenerImpl.clientIsFloating and
ServerGamePacketListenerImpl.aboveGroundTickCount. Two of those four
groups, the positions and the floating pair, have a vehicle twin beside them,
with ServerGamePacketListenerImpl.lastVehicle saying which vehicle —
ServerGamePacketListenerImpl.vehicleFirstGoodX,
ServerGamePacketListenerImpl.vehicleLastGoodX,
ServerGamePacketListenerImpl.clientVehicleIsFloating — which is the first
sign that a vehicle is judged by its own cut-down copy of this code.
On ServerPlayer itself there are only two: the observed per-tick
displacement, ServerPlayer.lastKnownClientMovement, read back through
ServerPlayer.getKnownMovement and ServerPlayer.getKnownSpeed, and the raw
key state, ServerPlayer.lastClientInput.
The thresholds below are given as numbers. Where one has a name a reader
can grep for, it is a compile-time constant, which a decompile shows as its
number at every use: ServerGamePacketListenerImpl.MAXIMUM_FLYING_TICKS is the
floating budget’s 80 ticks, and
ServerGamePacketListenerImpl.CLIENT_LOADED_TIMEOUT_TIME the client-load
timeout’s 60.
Sampled once a tick, judged once a tick
Keys are sampled inside the tick, not pushed from the callback. The
movement half of KeyboardHandler.keyPress sets KeyMapping.isDown and
bumps KeyMapping.clickCount, and does even that only when no screen is
open; everything else the method does — the screen’s own key handling, the
debug keys, the pause — reaches a KeyMapping only to clear one or to hold
the debug modifier down. A release is recorded screen or no screen, unless an
open screen consumes it, and a consumed release would leave a mapping stuck down were it not already released when the screen opened, which belongs to input and
keybinds,
along with when the callback runs. The read happens once per game
tick, deep inside LocalPlayer.aiStep, which calls
ClientInput.tick — the empty base method whose KeyboardInput override
does the work.
Mouse look is the exception, and it is the one input on this page not
sampled once a tick. MouseHandler.handleAccumulatedMovement runs per
frame, in Minecraft.runTick after the tick loop, and — when the window is
active and the mouse grabbed — MouseHandler.turnPlayer calls Entity.turn
directly. Rotation is therefore finer-grained than position, which is why a
packet can carry a rotation the tick never saw a keypress for. The
sensitivity curve inside MouseHandler.turnPlayer and the rest of the gates
on it belong to input and
keybinds.
On the server the ordering is the whole story:
MinecraftServer.processPacketsAndTickdrainsPacketProcessorbeforeMinecraftServer.tickServer. Every movement packet for the tick is applied first, ahead of any level ticking.- The levels tick.
MinecraftServer.tickChildrenreaches its connection phase, andServerGamePacketListenerImpl.tickrunsServerGamePacketListenerImpl.tickPlayer— the simulate-and-discard step, and the place the floating check is enforced. A paused server short-circuits before it.
W, held down: what the client decides and sends
The press and the reading of it are in different ticks, which is the first thing the figure shows.
sequenceDiagram
participant KH as KeyboardHandler
participant KM as KeyMapping
participant KI as KeyboardInput
participant LP as LocalPlayer
participant Wire as the network
KH->>KM: set, then click — isDown goes true, and nothing else yet
rect rgba(0, 0, 0, 0.04)
Note over KH,Wire: the next client tick — LocalPlayer.tick, then LocalPlayer.sendChanges
LP->>KI: tick, from inside aiStep — the override that does the work
KI->>KM: isDown, once for each of seven mappings
LP->>LP: applyInput — moveVector becomes xxa and zza, jump becomes jumping
LP->>LP: travel — travelInAir, then Entity.move, unguarded
LP->>Wire: ServerboundPlayerInputPacket, only if the key set changed
LP->>Wire: ServerboundMovePlayerPacket.<br/>PosRot
end
Nothing happens at the callback but a flag: the band is one client tick, and
every key the tick reads is read through KeyMapping, which is why a press
and a release of a held key inside one band never happened at all — and it is
LocalPlayer.sendPosition that decides which of the move variants leaves at
the foot of it.
The client half
KeyboardInput.tick builds an Input from the seven
keys and a normalised Vec2. LocalPlayer.applyInput — overriding a
LivingEntity.applyInput that does nothing but decay them —
turns that into the LivingEntity.xxa and LivingEntity.zza movement
fields, after passing it through
LocalPlayer.modifyInput: a flat 0.98 scaling, then the item-use
slowdown (from LocalPlayer.itemUseSpeedMultiplier),
Attributes.SNEAKING_SPEED when moving slowly, and
LocalPlayer.modifyInputSpeedForSquareMovement, the diagonal correction.
From there it is ordinary
movement and collision:
Player.travel — a real override, handling passengers, the swimming look
nudge and the creative-flight damping — then LivingEntity.travel →
LivingEntity.travelInAir →
LivingEntity.handleRelativeFrictionAndCalculateMovement →
Entity.moveRelative → Entity.move.
Sprint is decided in LocalPlayer.aiStep before that, and it is a
rising edge, not a release. LocalPlayer.aiStep snapshots the forward
impulse before ticking the input, so the value it later tests is the
previous tick’s; LocalPlayer.canStartSprinting requires the current
tick’s. The pair means the double-tap window — Options.sprintWindow, seven ticks by
default — is armed on the first press and consumed on the second. Letting
go is not what arms it, and letting go is not what cancels it either:
sneaking, using an item that forbids sprinting or walking backwards clear
LocalPlayer.sprintTriggerTime outright, and
LocalPlayer.shouldStopRunSprinting ends a sprint already running. Auto-jump is
LocalPlayer.updateAutoJump (called from LocalPlayer.move) setting
LocalPlayer.autoJumpTime, which makes the next tick call
ClientInput.makeJump.
What goes on the wire
Both packets leave from LocalPlayer.sendChanges, which Minecraft.tick
calls once the level has ticked its entities and block entities, and for a
player on foot LocalPlayer.sendPosition picks the variant:
ServerboundMovePlayerPacket.PosRot when both changed,
ServerboundMovePlayerPacket.Pos or .Rot for one,
ServerboundMovePlayerPacket.StatusOnly when only the ground or
collision flag changed, and nothing at all otherwise — except that a
position is re-sent every twenty ticks regardless, via
LocalPlayer.positionReminder. “Changed” is not the same test for the
two halves: rotation compares exactly, while position must have moved by
more than 2×10⁻⁴ blocks. And all of it but the sprint command sits behind
LocalPlayer.isControlledCamera, so while spectating another entity a
client sends no move packets at all, not even the reminder — which has one
side effect nobody would look for here, because LocalPlayer.sendPosition is
also where Options.autoJump is read back into
LocalPlayer.autoJumpEnabled, so the setting quietly stops tracking for a
passenger or a non-camera player. The two
booleans ride in one byte
(ServerboundMovePlayerPacket.FLAG_ON_GROUND,
ServerboundMovePlayerPacket.FLAG_HORIZONTAL_COLLISION).
ServerboundPlayerInputPacket is sent only when the key set changes,
and ServerboundClientTickEndPacket — a zero-byte singleton — closes
every client tick that has a level and is not paused.
Elytra, which is its own round trip
LocalPlayer.aiStep asks
Player.tryToStartFallFlying and, if it says yes, sends
ServerboundPlayerCommandPacket.Action.START_FALL_FLYING — the server runs
the same method on receipt. The server may
disagree and call LivingEntity.stopFallFlying, and the flight itself is
LivingEntity.updateFallFlying and LivingEntity.travelFallFlying.
What the server does with the packet it gets
Everything above happens before the packet exists. What follows is one
handler, ServerGamePacketListenerImpl.handleMovePlayer, and two judgements
inside the private ServerGamePacketListenerImpl.handlePlayerPositionChange
it calls — the method a teleport’s acknowledgement is run through too — and
then, because a judgement can go against you, a handshake for putting you back.
The two checks, in the order they run
sequenceDiagram
participant Wire as the network
participant SGPL as ServerGamePacket<br/>ListenerImpl
participant SP as ServerPlayer
rect rgba(0, 0, 0, 0.04)
Note over Wire,SP: the packet drain, before any level ticks
Wire->>SGPL: ServerboundMovePlayerPacket.<br/>PosRot
SGPL->>SGPL: containsInvalidValues, then the two clamps
alt moved too quickly
SGPL->>Wire: ClientboundPlayerPositionPacket, then return — nothing below runs
else within the budget
SGPL->>SP: move, with MoverType.PLAYER
opt a residual over 0.0625, or a new collider
SGPL->>Wire: ClientboundPlayerPositionPacket, awaiting an ack
end
end
end
rect rgba(0, 0, 0, 0.04)
Note over Wire,SP: the connection phase, after every level has ticked
SGPL->>SP: doTick, then absSnapTo — simulate the tick and discard it
end
The two bands are two phases of one server tick, and the rubber-band is the only arrow here that is conditional twice over: it needs the move applied first, and then either arm of the disjunction below.
ServerGamePacketListenerImpl.handleMovePlayer
begins with ServerGamePacketListenerImpl.containsInvalidValues, which
disconnects on NaN coordinates and non-finite rotations — an infinite
coordinate survives it and is clamped instead, to ±3×10⁷ horizontally by
ServerGamePacketListenerImpl.clampHorizontal and ±2×10⁷ vertically by
ServerGamePacketListenerImpl.clampVertical — and it disconnects a client
that sends a second position before the packet that closes its tick. It then
discards the position entirely while a teleport is outstanding, and
ServerGamePacketListenerImpl.handlePlayerPositionChange takes the rest: for
a passenger it applies rotation only — snapping the position back with
Entity.absSnapTo and re-registering the chunk position, which is not quite
“returns early” — and it short-circuits a sleeping player, teleporting them
back if they claim to have moved more than a block. Then two checks:
- moved too quickly: the squared distance from
firstGood…minusEntity.getDeltaMovement().lengthSqr()against a budget of 100 per packet, or 300 while fall-flying, scaled by how many move packets arrived since the last tick. Both sides are squared, so 100 is a hundred blocks squared — about ten blocks a tick. The whole check, and the packet counter it uses, is gated onTickRateManager.runsNormally, so a frozen world does no speed checking at all, though a stepped tick does. It is also skipped for the singleplayer host, during a dimension change, and whenGameRules.PLAYER_MOVEMENT_CHECKis off (GameRules.ELYTRA_MOVEMENT_CHECKcovers the elytra case). Failure teleports the player back and returns. - The move is then applied —
Entity.movewithMoverType.PLAYER— and moved wrongly measures what is left over: a residual above0.0625while not changing dimension, sleeping, creative, spectating or insideLivingEntity.isInPostImpulseGraceTime(the mace and wind-charge exemption, which the Lunge enchantment’s impulse opens too and which counts itself down unless a pearl, chorus fruit, fall damage or another explosion ends it first). The rubber-band that follows is a disjunction: either that failure with a demonstrably clear old box, orServerGamePacketListenerImpl.isEntityCollidingWithAnythingNewreporting the player ended up inside a collider it was not already inside — which fires whether or not the residual check failed. Both arms are additionally suppressed for a no-physics or sleeping player. The vertical half of that residual is dead code: the guard that zeroes it is a disjunction true for every finite double, so the 0.0625 test is horizontal-only in practice, in the vehicle handler as well as this one.
Accepting means Entity.absSnapTo, ServerChunkCache.move,
Entity.setOnGroundWithMovement, Entity.doCheckFallDamage,
ServerGamePacketListenerImpl.handlePlayerKnownMovement and
ServerPlayer.checkMovementStatistics — the walked-distance statistics
are computed from the client’s reported delta, never from a simulation.
The server also infers the jump, before the move is applied and whether or
not it is then accepted: a packet that reports leaving the ground while moving
upward, from a player the server has on the ground, calls
LivingEntity.jumpFromGround on the player’s behalf.
Where the velocity everything downstream reads comes from
The reported delta is stored by
ServerPlayer.setKnownMovement, and
ServerGamePacketListenerImpl.handleClientTickEnd zeroes it if no move was
accepted during that client tick. That is what everything downstream reads when it
wants the player’s velocity — whether a swing sweeps, what a spear’s charge
does, the speed a fired projectile inherits, leash physics — and it is why a
client that stops sending is treated as stationary rather than as still
coasting.
The teleport handshake, and what it does not undo
ServerGamePacketListenerImpl.teleport bumps
ServerGamePacketListenerImpl.awaitingTeleport, moves the player with Entity.teleportSetPosition,
records ServerGamePacketListenerImpl.awaitingPositionFromClient and sends
ClientboundPlayerPositionPacket (a PositionMoveRotation plus a set of
Relative flags saying which fields are deltas).
ServerGamePacketListenerImpl.updateAwaitingTeleport re-sends after
more than twenty ticks without an acknowledgement, when the next move
packet arrives, and until one does,
every incoming move packet contributes rotation only. That is also the answer
to what happens to the movement you made while the rubber-band was in
flight: nothing replays it. The client snaps, the packets it had already sent
are discarded as positions, and the ticks you walked between the rejection
and the snap are simply gone — unlike a block prediction, which survives the
same teleport on purpose. The client replies
with ServerboundAcceptTeleportationPacket, which carries the position it
snapped to and which the server runs through the same checks as a move, then calls
BlockStatePredictionHandler.onTeleport, which does not drop its
outstanding block predictions: it records the sequence the teleport arrived at, so that a receipt at or below that number settles those predictions without the position snap that would otherwise shove you back (prediction and
acknowledgement). On the
receiving end ClientPacketListener.handleMovePlayer applies the position
only when the player is not a passenger, and never interpolates: it passes
the interpolate flag as a literal false, so your own player is always
snapped, and the 4096-blocks-squared jump test inside it decides anything
only when a caller asks to interpolate, which this one never does.
ClientboundPlayerRotationPacket is the rotation-only sibling.
Floating, and everything exempted from it
The third judgement is not about a packet at all. It runs in the connection tick beside the bracket, and of the three it is the one that ends sessions.
Floating has a flat definition — nothing but air around you and for
just over half a block below, while you are not dropping — and a budget
that is anything but flat.
ServerGamePacketListenerImpl.getMaximumFlyingTicks returns an effectively
unbounded budget below a gravity of 10⁻⁵, and otherwise stretches the
eighty-tick budget as gravity falls; so the kick scales with gravity, and
only upward. ServerGamePacketListenerImpl.clientIsFloating is then
suppressed outright by six things: spectator mode, Abilities.mayfly, the
server’s own allow-flight setting, MobEffects.LEVITATION, fall-flying and
riptide — and the kick is not counted at all while you sleep, ride or die.
That list is why creative flight never trips it, and it is the list
the rest of the book points here for.
A floating vehicle runs a second copy of the whole check —
ServerGamePacketListenerImpl.clientVehicleIsFloating against
ServerGamePacketListenerImpl.aboveGroundVehicleTickCount, on its own budget
and only for the controlling passenger — so a rider and the thing they are
riding are judged separately, and either can end the session (players and
sessions).
What it calls, and what crosses the wire
- Called by:
Minecraft.tick(client: the input throughClientLevel’s entity tick, the move packets throughLocalPlayer.sendChanges);PacketProcessorandMinecraftServer.tickChildren(server). - Calls into:
LivingEntity.travelandEntity.move(movement and collision);ServerChunkCache.move, which is what makes chunks load as you walk (tickets and loading). - Crosses the network as:
ServerboundMovePlayerPacketand its four variants,ServerboundPlayerInputPacket,ServerboundPlayerCommandPacket(whoseServerboundPlayerCommandPacket.Actionis seven values — start and stop sprinting, start and stop riding-jump,ServerboundPlayerCommandPacket.Action.STOP_SLEEPING,ServerboundPlayerCommandPacket.Action.OPEN_INVENTORY,ServerboundPlayerCommandPacket.Action.START_FALL_FLYING; there is no sneak action — sneaking reaches the server throughServerboundPlayerInputPacket, which callsEntity.setShiftKeyDown; and the packet carries an entity id the server never validates against the sender),ServerboundMoveVehiclePacket,ServerboundAcceptTeleportationPacket,ServerboundClientTickEndPacket; and back,ClientboundPlayerPositionPacket,ClientboundPlayerRotationPacket,ClientboundMoveVehiclePacket. - Data-driven by: almost nothing —
GameRules.PLAYER_MOVEMENT_CHECKandGameRules.ELYTRA_MOVEMENT_CHECK(level data and rules), plus the movement attributes.
Questions players ask
If ServerboundPlayerInputPacket never moves me, what is it for?
Three things a player notices. ServerPlayer.setLastClientInput feeds
ServerPlayer.getLastClientMoveIntent, and both NewMinecartBehavior and
OldMinecartBehavior read it to nudge a stalled cart along the rider’s
intended direction — so the packet that cannot move a player can move a
minecart — and the same stored input is what a data pack’s player predicate
tests (PlayerPredicate). And the handler sets the sneak flag directly, which
is why there is no sneak action on ServerboundPlayerCommandPacket. And a key
change counts as activity, resetting the idle timer
(ServerPlayer.resetLastActionTime). Boats are steered
client-side (LocalPlayer.rideTick → AbstractBoat.setInput) and the
result ships as ServerboundMoveVehiclePacket.
Does sending move packets faster help me cheat? It does the opposite.
The per-packet budget normally scales with how many packets arrived since
the last tick, but above five the code clamps the count to one — so a flood
gets a one-packet budget for a many-packet displacement. And a second
position inside one client tick, before the ServerboundClientTickEndPacket
that closes it, is a disconnect; beyond that, only chat, commands and item
drops have a TickThrottler, and a server that sets a rate limit kicks any
flood of packets (the connection).
Why is a passenger barely checked? A passenger’s own move packet contributes rotation and a chunk re-registration and nothing else, and the vehicle’s packet is judged by a cut-down copy of the same code: a flat budget of 100, no elytra case, no game rule, and no horizontal-collision flag.
Why did my quick tap do nothing? Movement polls KeyMapping.isDown once
a tick, so a press and release that both fall between two ticks never happened
— unless the key is sneak or sprint set to toggle, where the press alone flips
the state. The keys that use
KeyMapping.consumeClick behave the other way: three taps inside one tick
can fire three times.
Why does brushing a wall sometimes cancel my sprint and sometimes not?
LocalPlayer.isHorizontalCollisionMinor is a client-only override that
measures the angle against LocalPlayer.MINOR_COLLISION_ANGLE_THRESHOLD_RADIAN,
about eight degrees; a graze shallower than that is forgiven. The server has
no equivalent.
Where to look
The client half reads shortest first: KeyboardInput.tick is the whole
of the sampling, LocalPlayer.aiStep the tick it happens inside, and
LocalPlayer.sendPosition the method that decides what, if anything,
leaves. MouseHandler.handleAccumulatedMovement is the per-frame
exception beside them, and KeyboardHandler.keyPress the callback that
does less than its name suggests.
The server half is essentially one class. Read
ServerGamePacketListenerImpl.handleMovePlayer and the
ServerGamePacketListenerImpl.handlePlayerPositionChange it calls end to
end — between them they are the speed check, the move, the residual check and
the rubber-band —
then ServerGamePacketListenerImpl.handleMoveVehicle beside it to see
the same logic with the elytra case and the game rule taken out. On the wire,
ServerboundMovePlayerPacket with its four variants and
ClientboundPlayerPositionPacket with its PositionMoveRotation and
Relative flags are worth opening for their shape alone. Two doors this page
only points at: PacketProcessor, which is what drains a move packet
before any level ticks, and TickThrottler, which is what movement
notably does not have.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
The sword swing
Verified against Minecraft 26.3 · Part VIII · Left-click on a pig: the client picks a target and sends one integer, and the server rebuilds every part of the hit from scratch.
You put the crosshair on a pig and click. The client has already decided
what you are looking at — earlier in this same tick — checks a handful of
reasons not to swing, and sends a packet with almost nothing in it:
ServerboundAttackPacket is a record of one int, the entity id. No
hand, no sneak flag, no hit position, no damage. Everything else the server
re-derives: the weapon from your main hand, the geometry from the target’s
bounding box measured against your eye, and the damage from an attribute, a cooldown curve applied twice in
two different shapes, and a multiplication order in which the mace’s fall
bonus lands before the critical hit and is therefore multiplied by it.
The cast
| class | what it decides | thread |
|---|---|---|
Minecraft | what you are looking at, and whether the click swings at all | Render |
LocalPlayer | the raycast, and which range its winner is judged against | Render |
MultiPlayerGameMode | sends the attack, and predicts almost nothing | Render |
ServerGamePacketListenerImpl | resolves the id, re-checks the range and the item | Server |
Player | Player.attack: one method, and the order inside it is load-bearing | both (only the server’s answer counts) |
LivingEntity | the swing animation state, and the two attack clocks | both |
AttackRange | a weapon’s own reach, with a minimum as well as a maximum | — |
Picking: what is under the crosshair
Minecraft.pick runs before Minecraft.handleKeybinds in the client tick
(the client
loop), and the keybind
drain is what turns Options.keyAttack into Minecraft.startAttack — so
the hit result a click uses was computed earlier in the same tick.
Minecraft.pick also runs per frame, for the crosshair and the block
outline, but that value is not what the attack sees.
It asks the local player, through LocalPlayer.raycastHitResult, with the
camera entity as the ray’s origin. A player carries two reaches, and the
whole of this section turns on their being different numbers:
Attributes.BLOCK_INTERACTION_RANGE (4.5) and
Attributes.ENTITY_INTERACTION_RANGE (3.0) (player
anatomy).
If the active item (the one being used, if any, else the main hand)
carries an AttackRange (DataComponents.ATTACK_RANGE), that component’s
own search runs first, and the classic algorithm runs only when that search
comes back empty: a block clip out to the greater of the two ranges, an
entity sweep with
ProjectileUtil.getEntityHitResult over the bounding box expanded along the
view direction and inflated by one, each candidate inflated by
Entity.getPickRadius (zero for everything but a pickable projectile) — and the
entity wins only if it is strictly nearer than the block. Then
LocalPlayer.filterHitResult measures the winner against its own one of
the two reaches, an entity against 3.0 and a block against 4.5. That is the
divergence outside creative: a wall four blocks away is in reach and a mob
four blocks away is not — and a mob at four blocks in front of a wall hides the wall, because the
mob wins the ray and is then turned into a miss.
AttackRange is worth a second look, because it is a reach floor as well
as a ceiling: a minimum and a maximum, separate creative values, a hitbox
margin and a mob factor. AttackRange.isInRange is the test,
AttackRange.defaultFor falls back to Attributes.ENTITY_INTERACTION_RANGE,
and AttackRange.effectiveMinRange / AttackRange.effectiveMaxRange apply
the mob factor — only for non-players.
Deciding: most branches do not swing
Minecraft.startAttack is the branch point. It returns early — no swing, no
packet — while Minecraft.missTime is running, when there is no hit result
at all (setting a ten-tick miss time), while LocalPlayer.isHandsBusy, for
a disabled item, and when Player.cannotAttackWithItem refuses with a
tolerance of zero; spectators take a branch of their own. Two branches
do swing: the piercing short-circuit to MultiPlayerGameMode.piercingAttack
when the item carries DataComponents.PIERCING_WEAPON — the
spear — and the tail of the hit-result switch: entity to
MultiPlayerGameMode.attack, block to
MultiPlayerGameMode.startDestroyBlock (block
breaking), a miss (nothing in reach, or an air block) to
Player.resetAttackStrengthTicker and the ten-tick miss time. Even the
entity branch is conditional — a weapon with its own AttackRange that the
hit falls outside of swings but sends no attack packet at all. The ten-tick
miss time only exists outside creative, and opening any screen parks the timer
at a very large number in every mode.
On the server, ServerGamePacketListenerImpl.handleAttack requires the
client to have loaded and the player not to be a spectator, resolves the id
with ServerLevel.getEntityOrPart, checks the world border, applies
Player.isWithinAttackRange with a 3.0-block server buffer — applied to
both ends, so a weapon’s minimum range effectively vanishes server-side —
rejects a piercing weapon (that path arrives elsewhere), checks the item is
enabled, re-checks Player.cannotAttackWithItem with a tolerance of five
ticks, more lenient than the client’s zero, and calls Player.attack.
Attacking something absurd — an ItemEntity, an ExperienceOrb, an
unattackable AbstractArrow, yourself — is a disconnect, not a
rejection; failing the range check is a silent drop.
The packet is drained from PacketProcessor at the top of the tick,
before MinecraftServer.tickServer and therefore before any level ticks.
That ordering matters: Player.attack runs before the victim’s
damage cooldown comes down for the tick (damage and
death
owns LivingEntity.damageCooldownTime and who decrements it when). Each of the
resulting feedback packets is written and flushed on its own: the
connection suspends flushing across MinecraftServer.tickChildren (and
across a player’s join), and the attack was handled before that bracket
opened.
One click, one integer, one round trip
Two packets go out, and the one that comes back for the hit, the damage event, has no damage number in it.
sequenceDiagram
box transparent the client
participant LP as LocalPlayer
participant MPGM as MultiPlayer<br/>GameMode
participant CPL as ClientPacketListener
end
box transparent the server
participant SGPL as ServerGamePacket<br/>ListenerImpl
participant Player as Player
participant Entity as Entity
end
LP->>LP: raycastHitResult — AttackRange first, then the classic pick
LP->>MPGM: attack, from Minecraft.startAttack
MPGM->>SGPL: ServerboundAttackPacket — one varint, the entity id
LP->>SGPL: ServerboundPunchPacket — no fields, from Minecraft.startAttack
SGPL->>SGPL: ServerLevel.<br/>getEntityOrPart
SGPL->>Player: isWithinAttackRange — the reach, plus a 3.0 buffer
SGPL->>Player: attack — the damage rebuilt from nothing but the id
Player->>Entity: hurtOrSimulate — its answer gates the tail
Entity->>CPL: ClientboundDamageEventPacket — a damage type and three ids
Player->>Player: causeExtraKnockback, doSweepAttack, itemAttackInteraction
Everything the client sent is on the two arrows that cross to the right, and between them they carry one integer: every number in the box on the right was rebuilt there, and the arrow coming back carries none of them.
The two clocks a swing is charged against
Before the arithmetic, the thing the arithmetic reads. A player carries two
attack clocks, and both are declared two rungs up, on LivingEntity, while
being read, reset and incremented only from Player:
LivingEntity.attackStrengthTicker and LivingEntity.itemSwapTicker.
The first is the one you watch. Player.getCurrentItemAttackStrengthDelay is
twenty divided by Attributes.ATTACK_SPEED, and the scale the damage uses is
how far through that delay the ticker has got.
Player.resetAttackStrengthTicker clears both clocks;
Player.resetOnlyAttackStrengthTicker clears one. Player.tick resets both
when the main-hand item type changes — swap weapons and you start again.
The second exists only to drive the held-item swap animation.
Player.onAttack clears the attack ticker and leaves the swap ticker alone,
but it is not the only reset a swing gets: both sides reset the attack ticker
twice per swing, once inside Player.attack and once after it with
Player.resetAttackStrengthTicker, which clears both clocks — the client in
MultiPlayerGameMode.attack, the server on the punch packet that follows,
because ServerGamePacketListenerImpl.handlePunch resets the ticker too.
The damage: one number, two curves, one order
Player.attack is a single method, and everything interesting about melee
combat is the order in which it touches one float.
flowchart TD
S["s = Player.getAttackStrengthScale, at a partial tick of 0.5"]
BASE["base = Attributes.ATTACK_DAMAGE"]
BOOST["boost = Player.getEnchantedDamage minus base, times s"]
BQ["base × (0.2 + s² × 0.8) — Player.baseDamageScaleFactor"]
RESET["Player.onAttack — the ticker resets, the scale already read"]
GATE{"base or boost<br/>above zero?"}
NONE["nothing below runs"]
KB["sprint knockback, if s is above 0.9 and you are sprinting"]
ITEM["base += Item.getAttackDamageBonus — the mace's fall bonus"]
CRIT["base × 1.5, if full strength and Player.canCriticalAttack"]
TOTAL["total = base + boost"]
HURT["Entity.hurtOrSimulate — was anything damaged?"]
S --> BOOST
S --> BQ
BASE --> BOOST
BASE --> BQ
BQ --> RESET
RESET --> GATE
GATE -- "neither" --> NONE
GATE -- "either" --> KB
KB --> ITEM
ITEM --> CRIT
CRIT --> TOTAL
BOOST --> TOTAL
TOTAL --> HURT
One float, followed down: the short branch is the enchantment bonus, which is scaled once and then left alone, and the long one is the base damage, which is scaled, gated, added to and multiplied before the two meet again at the bottom.
One node in it needs its meaning before the rest of the page spends it.
Entity.hurtOrSimulate is the wrapper that branches on the side —
Entity.hurtServer on the server, Entity.hurtClient on the client (damage
and death owns the
wrapper itself) — and what it returns is not the obvious thing: the boolean
means was anything damaged, not did the hit land. That is what gates
the knockback, the sweep, the durability and the hit particles below.
Read the picture for the two things it makes obvious. The cooldown is
applied twice, differently — Player.baseDamageScaleFactor is the
quadratic ramp on the base damage, and a plain multiplication is the linear
one on the enchantment bonus, both from the same scale read with the same 0.5
partial tick. And the item bonus is inside the crit, because
Item.getAttackDamageBonus is added before the ×1.5 rather than after it.
One substitution the figure leaves out: while an auto-spin attack is in
flight the base is LivingEntity.autoSpinAttackDmg, the riptide number, instead of
the attribute.
The gates along the way are as particular as the arithmetic, and the first
two are put to the target rather than to the attacker.
Player.cannotAttack asks it two things: Entity.isAttackable, which
is true by default and false for a dropped item, an experience orb, a falling
block, a thrown eye of ender, a firework rocket and any arrow outside
EntityTypeTags.REDIRECTABLE_PROJECTILE, and Entity.skipAttackInteraction,
the hook by which a thing claims the swing for itself — an Interaction
entity uses it to record
who hit it, and a BlockAttachedEntity to re-enter through
Entity.hurtOrSimulate with zero damage. A no from the first or a yes from the second ends the swing
(damage outside LivingEntity has
the per-class table). Then Player.deflectProjectile, which is why a
ghast fireball can be batted back: anything in
EntityTypeTags.REDIRECTABLE_PROJECTILE is turned around here rather than damaged, and the attack ends — all but a player’s wind charge in its first five ticks, which WindCharge will not let be deflected.
Then three tests that are not gates but switches, each deciding what kind
of hit this is. Sprint knockback needs the scale above 0.9, plays a
sound, and adds a flat 0.5 to the knockback later; it is the step the
item bonus is added immediately after, which is why the item-bonus node in the
figure above sits where it does. Player.canCriticalAttack
needs falling, not on the ground, not climbing, not in water, not
mobility-restricted, not a passenger, not sprinting, a LivingEntity
target — and full strength as well. Player.isSweepAttack needs full
strength, not a crit, not sprint knockback, on the ground, moving slower
than 2.5× the walking speed, and something in ItemTags.SWORDS.
If the hit landed, the tail runs in order: Player.causeExtraKnockback —
which is also where the attacker’s own motion is damped and sprinting
cancelled, using LivingEntity.getKnockback computed from
Attributes.ATTACK_KNOCKBACK through the enchantments and halved — then
Player.doSweepAttack, Player.attackVisualEffects,
LivingEntity.setLastHurtMob, Player.itemAttackInteraction,
Player.damageStatsAndHearts, and Player.causeFoodExhaustion of 0.1
(hunger and experience). If
it did not land, a no-damage sound. Either way LivingEntity.postPiercingAttack
runs at the end — the same hook a stab ends on, which is why the name says
piercing on a method that closes an ordinary swing (the
spear).
Player.itemAttackInteraction is itself three steps in a particular order:
ItemStack.hurtEnemy (the item’s own hook and the use statistic, not
durability), then EnchantmentHelper.doPostAttackEffectsWithItemSource
(enchantments),
then ItemStack.postHurtEnemy, which is where Weapon’s per-attack
durability cost is applied. Weapon (DataComponents.WEAPON) is a pair:
that cost, and Weapon.disableBlockingForSeconds, the axe’s shield-breaking
rule. LivingEntity.getSecondsToDisableBlocking reads it back, but only when
the weapon in the attacker’s hand is also their active item — so an axe
swung while the other hand is using something disables no shield.
Player.doSweepAttack damages every living entity in a box around the primary
target inflated by (1, 0.25, 1), for candidates within three blocks of the
attacker, excluding the attacker, the primary target, allies and marker
armour stands. Each takes 1.0 + Attributes.SWEEPING_DAMAGE_RATIO × base,
run through Player.getEnchantedDamage and then scaled by the
attack-strength scale, plus a flat 0.4 knockback. Its sweep sound is
unguarded; the damage and the ParticleTypes.SWEEP_ATTACK particles sit
behind the server check.
Armour, the hurt cooldown, DataComponents.BLOCKS_ATTACKS and knockback
resistance decide how much of that damage arrives, and all four belong to damage
and
death.
Questions players ask
Why does mashing do less damage? Because the base damage is quadratic in the charge and the enchantment bonus is linear in it. At half charge the base is 0.2 + 0.25 × 0.8 — that is s² with s = 0.5 — so 40% of full, while the enchantment bonus is at the full 50%. Mashing costs you more of the sword than of the enchantments on it.
Why does my sword make no sound until the server answers? Because the
attack sounds are the counter-example to the client’s own prediction rule:
Player.playServerSideSound excludes nobody, so every hit sound the attacker
hears arrives as a ClientboundSoundPacket, one round trip late (what makes
a sound).
Does the client predict any of this against a mob? Almost none.
Entity.hurtClient returns false and neither LivingEntity nor Mob
overrides it, so on the client Entity.hurtOrSimulate reports that the hit
did not land and the entire block after it is skipped: no predicted
knockback, no sweep, no visual effects, no durability, no exhaustion. The
exceptions are the nine classes that do override it, and none of them is a
mob — a RemotePlayer, a vehicle, a hanging thing, an orb (damage outside
LivingEntity has the roll-call).
Against four of them — another player, a vehicle, an end crystal, a shulker
bullet — the whole block runs locally; a dropped item and an orb cannot be
attacked at all, and the block-attached ones either claim the swing through
Entity.skipAttackInteraction or refuse it in Entity.hurtClient. Player.getEnchantedDamage does nothing on Player either;
it returns its argument unchanged and only ServerPlayer overrides it. With
Attributes.ATTACK_DAMAGE not being client-syncable
(attributes), the client’s damage figure is
never authoritative wherever that block does run. That the client may not
decide a hit at all is the rule of authority,
applied to combat.
Can a weapon be too close to swing? No: it swings, and what an
AttackRange minimum withholds on the client is the attack packet. The
server’s attack path would enforce at most the floor less three blocks — the
3.0-block leniency is subtracted from the minimum as well as added to the
maximum, which erases any floor under three — but
the one built-in weapon with a floor, the spear, never takes that path, and
its stab’s own search starts at the floor.
How does my client know how badly the pig was hurt? It learns the new
health, not the hit. ClientboundDamageEventPacket carries no amount at all —
a damage-type holder, three entity ids and an optional source position — and
the victim’s red flash and damage cooldown are reconstructed from it, with the
hurt sound too when the victim is you; a pig’s hurt sound arrives as a sound
packet of its own (damage and
death
owns the packet). The health itself comes from synched entity
data.
Are sweep and knockback enchantment effects? They are attributes, and the
two enchantments reach them differently: Sweeping Edge raises
Attributes.SWEEPING_DAMAGE_RATIO with an attribute modifier, while Knockback
leaves Attributes.ATTACK_KNOCKBACK alone and adds to the number read from it,
through an effect on the weapon. Attributes.SWEEPING_DAMAGE_RATIO defaults to zero, so a vanilla sweep does
1.0 — scaled by the attack-strength ratio, so slightly less than 1.0
anywhere in the sweep’s legal window below full charge. And
Attributes.ATTACK_KNOCKBACK defaults to zero, so for an unenchanted sword
the entire attacker-side knockback is the sprint bonus of 0.5.
What makes my own arm move? Your own client does, before the server hears
about it. Every branch of Minecraft.startAttack that swings at all calls
LivingEntity.swing, which on the client only sets the animation state, and
the tail of the hit-result switch then sends ServerboundPunchPacket, a
packet with no fields at all. That is why a miss on an air block still animates,
and why the animation never waits for a round trip. The server’s own
LivingEntity.swing then broadcasts ClientboundSwingAnimationPacket to your
trackers — the players who can see you — and not back to you, because you
played it a round trip ago.
Why does my swing look different with a different weapon? Because swing
duration is a data component — ItemStack.getAttackAnimation returns a
SwingAnimation — not a constant six ticks;
MobEffects.MINING_FATIGUE stretches it and haste shortens it. The
animation state itself is one LivingEntity.SwingState, which holds the
current LivingEntity.SwingDescription (hand, animation and length in
ticks), a tick count and the progress from zero to one, and
ServerGamePacketListenerImpl.handlePunch is where the server receives yours. Crit particles are the exception to the
never-echoed rule: they go to the attacker’s trackers and to the attacker
too, while naming the victim.
Is this the only way to hit something in melee? No. Two other paths end
in damage and neither goes through Player.attack: a PiercingWeapon
short-circuits before the hit-result switch, and a KineticWeapon is
reached from item use rather than attack. Both are the
spear.
For a 1.21-era reader. ServerboundSwingPacket and LocalPlayer.swing are gone: after the local
LivingEntity.swing,Minecraft.startAttacksendsServerboundPunchPacketitself, and the server passes the swing on asClientboundSwingAnimationPacketrather than as aClientboundAnimatePacketaction. The fields swinging, swingingArm, swingTime and attackAnim are now oneLivingEntity.SwingState, and the hurt cooldown that wasEntity.invulnerableTimeisLivingEntity.damageCooldownTime; the old name is now a separate timer that a hit does not set.
Where to look
Two methods are the whole lecture, and they are worth reading in this order.
Minecraft.startAttack is the client’s branch point: almost every reason
not to swing is in it (the rest are around it: Minecraft.tick skips the
keybind drain while a screen or an overlay is up, and Minecraft.handleKeybinds swallows
the click while you are using an item), and so are the two branches that do.
Player.attack is the server’s, and everything surprising about melee
combat is the order of the lines in it — read it once for the shape and a
second time for where Player.baseDamageScaleFactor and
Item.getAttackDamageBonus land relative to the ×1.5.
Around them: ServerGamePacketListenerImpl.handleAttack for the nine
checks between the packet and the method, AttackRange for the reach
floor the server subtracts away, and Weapon for the two-field component
that charges the durability. Player.doSweepAttack and
Player.itemAttackInteraction are the two halves of the tail worth
opening on their own. On the wire, ServerboundAttackPacket takes ten
seconds to read and ClientboundDamageEventPacket is worth the same for
what it leaves out. Two doors this page only points at:
ProjectileUtil, which is where the entity sweep happens, and
SwingAnimation, the component behind the arm.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
The spear
Verified against Minecraft 26.3 · Part VIII · Two ways to hit something with the same item: jab it, and the client sends no target at all; charge it and run, and the damage comes from how fast the gap is closing.
A spear is one item with two weapons in it. Left-click and you stab: the
client sends a packet with no entity id in it, and the server does its own
raycast and hits everything along the ray. Hold right-click and you
charge: the spear becomes an item you are using, like a bow, except that
what it does each tick is look for entities in front of you and hurt them by
an amount that grows with the closing speed. Neither path enters Player.attack, the
method the sword
swing is
about, and the second has a property no other melee attack in the game has.
Every ordinary swing is scaled by how far the attack-strength clock has
recharged — quadratically on the base damage, linearly on the enchantment
bonus, so a sword swung at half charge does 40% of its base damage (the two
clocks).
A charging spear ignores that scaling entirely, because the code that
applies the two curves is skipped for the item you are currently using, and a
charge is a use.
The cast
| class | what it decides | thread |
|---|---|---|
PiercingWeapon | the stab — and, through its filter, who either weapon is allowed to hit along a ray | Server (its attack sound plays on both sides) |
KineticWeapon | the charge: three speed conditions, and the damage from closing speed | Server |
Item.Properties.spear | the seven spears, and the combat components that make one | — |
Minecraft / MultiPlayerGameMode | the client’s short-circuit, and the packet with no target | Render |
ServerGamePacketListenerImpl | ServerboundPlayerActionPacket.Action.STAB, and the piercing rejection in the ordinary attack handler | Server |
LivingEntity.stabAttack | the general tail — damage, two knockbacks, dismount — which only a mob’s charge runs | Server |
Player.stabAttack | a player’s whole tail instead, durability included, with the cooldown curves — sometimes | Server |
SpearUseGoal / SpearAttack | how a zombie or a piglin runs the charge | Server |
What an item needs to be a spear
Item.Properties.spear is one builder call per material, and the seven
spears — Items.WOODEN_SPEAR through Items.NETHERITE_SPEAR — differ
almost only in the numbers it is given; the wooden one gets its own sounds
and burns as fuel, and the netherite one is fire-resistant. What it attaches is the interesting part,
because it is both weapons at once plus the reach to use them:
| component | what the spear gets |
|---|---|
DataComponents.PIERCING_WEAPON | knockback yes, dismount no, an attack sound and a hit sound |
DataComponents.KINETIC_WEAPON | a contact cooldown of ten ticks, a delay, three conditions, and a damage multiplier |
DataComponents.ATTACK_RANGE | AttackRange.minReach of 2.0 and AttackRange.maxReach of 4.5 — 2.0 and 6.5 in creative — with a hitbox margin and a mob factor |
DataComponents.MINIMUM_ATTACK_CHARGE | 1.0: no stab before the attack clock is full |
DataComponents.ATTACK_ANIMATION | SwingAnimationType.STAB, with a per-material duration |
DataComponents.DAMAGE_TYPE | DamageTypes.SPEAR, as a delayed holder component |
DataComponents.USE_EFFECTS | the whole component overridden: sprint allowed, vibrations off, speed multiplier one — the one definition in the game, shared by the seven spears, that does not slow you down while you hold it out |
DataComponents.WEAPON | a durability cost of one per attack |
| attribute modifiers | Attributes.ATTACK_DAMAGE from the material, and an Attributes.ATTACK_SPEED derived from the swing duration |
Four of those rows are what the rest of this page is about — the two weapon
components, the reach they share, and the UseEffects override. The other
five are what stops a spear behaving oddly in the hand: no stab before the
attack clock is full, a stab animation rather than a slash, its own damage type in the death
message, a durability cost of one per attack, and the attack damage and speed
its attribute modifiers set.
The ordinary tool components come from the material and are not in the table:
Item.Properties.spear also calls Item.Properties.durability,
Item.Properties.repairable and Item.Properties.enchantable, so a spear
takes damage, mends on an anvil and accepts enchantments the way any tool
does (items and stacks and
enchanting).
That UseEffects override is why a spear feels unlike every other held-down
item — LocalPlayer.itemUseSpeedMultiplier and
LocalPlayer.isSlowDueToUsingItem are the readers it turns off, and
using an item explains what
the component does for everything else.
Two entries, one exit
flowchart TD
CLICK["left-click: Minecraft.startAttack"]
HAS{"main hand:<br/>PIERCING_WEAPON?"}
NORMAL["the ordinary path: MultiPlayerGameMode.attack, then Player.attack"]
REFUSE["and ServerGamePacketListenerImpl.handleAttack refuses a piercing weapon"]
PA["MultiPlayerGameMode.piercingAttack — the sound, and the ticker locally"]
PKT["ServerboundPlayerActionPacket.Action.STAB — no entity id, a dummy position"]
SGPL["ServerGamePacketListenerImpl.handlePlayerAction — not a spectator, and Player.cannotAttackWithItem with a 5-tick tolerance"]
PW["PiercingWeapon.attack — the server's own raycast"]
USE["right-click: Item.use sees KINETIC_WEAPON, then LivingEntity.startUsingItem"]
TICK["ItemStack.onUseTick, every use tick of the 72000"]
KW["KineticWeapon.damageEntities — server side only: the ticks used, the look vector, the closing speed"]
RAY["ProjectileUtil.getHitEntitiesAlong — every entity on the ray, filtered by PiercingWeapon.canHitEntity"]
STAB["Player.stabAttack — or a mob's LivingEntity.stabAttack"]
CLICK --> HAS
HAS -- "no" --> NORMAL
NORMAL --> REFUSE
HAS -- "yes" --> PA
PA --> PKT
PKT --> SGPL
SGPL --> PW
USE --> TICK
TICK --> KW
PW --> RAY
KW --> RAY
RAY --> STAB
Two entries — a click on the left, a held right-click on the right — meeting at one raycast, and the only place the two columns touch is that meeting: the left column’s refusal node is the reason a client cannot route a stab down the ordinary path.
Two things in that picture are worth stopping on. The client tells the
server nothing about the target on the stab path: the packet is a
ServerboundPlayerActionPacket carrying
ServerboundPlayerActionPacket.Action.STAB, whose block position and
direction are dummies, and every question about what was hit is answered by
the server’s own raycast. And the ordinary attack handler refuses a
piercing weapon — ServerGamePacketListenerImpl.handleAttack checks for
DataComponents.PIERCING_WEAPON and drops out — so the two paths cannot be
confused for one another even by a client that tries.
The stab
PiercingWeapon.attack takes the attacker’s Attributes.ATTACK_DAMAGE,
the weapon in the given slot and LivingEntity.getAttackRangeWith, and
walks ProjectileUtil.getHitEntitiesAlong with the block-collider clip
context — so a wall stops the ray, but a crowd does not. Every entity
along it is stabbed — in the order the search of the box around the ray
returned them, which is not sorted by distance — each through the attacker’s
Player.stabAttack with the same damage figure.
PiercingWeapon.canHitEntity is the filter, and it is a projectile-shaped
test rather than a melee one: the target must not be
Entity.isInvulnerableToPiercingWeapon, must be alive, and must satisfy
Entity.canBeHitByProjectile. Player against player defers to
Player.canHarmPlayer, and an entity riding the same vehicle as the
attacker is not hit. An Interaction — the invisible click-target entity a
data pack places to catch hits — short-circuits the rest of the filter to
hittable once it is alive and not immune, same vehicle included. And
Player.stabAttack still puts the melee gate, Player.cannotAttack, to every
target the filter lets through.
Afterwards the attacker gets LivingEntity.onAttack — which on a Player
resets the attack-strength ticker — and LivingEntity.postPiercingAttack,
the hook that runs EnchantmentHelper.doPostPiercingAttackEffects; only
then does the weapon play PiercingWeapon.makeHitSound if anything was hit
and PiercingWeapon.makeSound regardless, and swing the arm. The client
half did the swing and LivingEntity.onAttack itself as it sent the packet,
and called LivingEntity.postPiercingAttack too, which does nothing off a
ServerLevel.
The charge
A spear’s kinetic weapon is built on speed: its damage comes from how fast the
gap is closing, its dismount and knockback need you to be moving, and the
UseEffects override above exists so that you can sprint while charging. The
two halves are one design.
A kinetic weapon is also used, not swung. Item.use sees
DataComponents.KINETIC_WEAPON, calls LivingEntity.startUsingItem and
plays the sound; Item.getUseDuration returns 72000 for it, the same effectively-endless duration a bow gets, so in play the charge runs
until you release it, the spear leaves your hand, or you die or change
dimension (using an
item). Starting also
allocates LivingEntity.recentKineticEnemies, a server-side map of who the
charge has reached and when, which LivingEntity.stopUsingItem throws away.
Each use tick, ItemStack.onUseTick diverts to
KineticWeapon.damageEntities — and skips the item’s own
Item.onUseTick when it does. What that method computes is a speed
argument, not a swing:
- How long you have been charging. Ticks used must be at least
KineticWeapon.delayTicks; everything below is measured from there. - How fast you are going, along your look vector.
KineticWeapon.getMotionreadsEntity.getKnownSpeed— the reported movement from input to movement — scaled to blocks per second, taking the root vehicle’s motion for a non-player passenger (input to movement). - How fast the gap is closing. The target’s own projected speed is subtracted, floored at zero, and that relative speed is what the damage is built from.
- Whether you already reached them.
LivingEntity.wasRecentlyStabbedagainstKineticWeapon.contactCooldownTicks— ten for a spear — is why running through a crowd does not hit the same mob every tick.
Who is in front of you is answered exactly as it is for the stab:
ProjectileUtil.getHitEntitiesAlong walks the weapon’s AttackRange with
the block-collider clip context and PiercingWeapon.canHitEntity filters
what it finds. The two attacks differ in what they compute, never in what
they can reach.
Three independent KineticWeapon.Conditions then decide what the hit is,
and the point is that each gates its own effect rather than the hit as a
whole: KineticWeapon.dismountConditions, KineticWeapon.knockbackConditions
and KineticWeapon.damageConditions. Each is a maximum duration and two speed
bars, one on the attacker’s own projected speed and one on the closing speed;
a spear’s first two set the first bar and its damage condition the second. If any of the three passes the
attack happens at all; then the dismount only dismounts if its own condition
passed, the knockbacks only fire if theirs did, and the damage is only
dealt if the damage condition passed. A charge can knock a target off a horse and deal it no damage.
A spear’s three come from the builder with different windows, and for all
seven materials they nest the same way: damage has the longest window and the
lowest threshold (on the closing speed), dismount the shortest window and much
the highest. A wooden spear
dismounts for five seconds, knocks back for ten and damages for fifteen — so
a charge that has run too long can still hurt when it can no longer unseat
anyone. When the damage does land it is the attacker’s base
Attributes.ATTACK_DAMAGE plus the floor of relative speed ×
KineticWeapon.damageMultiplier — the base value, so the modifiers a sword
swing would pick up are not in it.
A landed charge broadcasts an entity event, and it is the part of the
telling that is about the charge, alongside the ordinary damage sync,
knockback and, for a player, durability that every hit sends:
LivingEntity.onKineticHit plays a local hit sound, never within ten ticks of
the last — the number KineticWeapon.HIT_FEEDBACK_TICKS names — and
LivingEntity.getTicksSinceLastKineticHitFeedback feeds the animation. A
ServerPlayer also trips CriteriaTriggers.SPEAR_MOBS_TRIGGER with the
number of living entities the charge has reached.
The tail, and the cooldown that is not applied
Both paths end in a method called stabAttack, which exists twice.
LivingEntity.stabAttack is the general one, and a mob’s charge is the only
thing that runs it: it returns false off a
ServerLevel, runs the damage through EnchantmentHelper.modifyDamage,
calls Entity.hurtServer — into the same pipeline a sword swing ends in
(damage and death)
— applies two knockbacks, a flat one and LivingEntity.getKnockback, and
dismounts the target, each of the three behind its own flag. Two things then
run on different conditions again: ItemStack.hurtEnemy for any living
target whether or not the damage landed, and the post-attack enchantment
effects (enchantments) only if it did. The attack
sound plays once anything at all happened.
Player.stabAttack overrides it whole, never calling it, and the override is
where the spear becomes strange. It computes the enchantment boost the way Player.attack
does, and then applies the two cooldown curves — the linear one to the
boost, the quadratic Player.baseDamageScaleFactor to the base — only if
the player is not currently using an item in that slot. A stab qualifies,
so a stab is charged like a sword swing. A kinetic charge does not: while
you are holding the spear out, both curves are skipped and every tick’s hit
lands at full base damage. The rest of the override is the familiar tail —
Player.deflectProjectile can still end it, the knockbacks are the same
two, Player.itemAttackInteraction applies the durability cost, and
Player.causeFoodExhaustion charges the same 0.1 a sword does (hunger and
experience).
What a mob does with the same item
A mob runs only the charge — the stab reaches PiercingWeapon.attack only
from a player’s packet — and the split between the two ways it can run it is
the ordinary one (goals and brains).
SpearUseGoal drives the charge for a goal-based mob; SpearApproach,
SpearAttack and SpearRetreat do it for a brain-based one. Zombies,
zombified piglins and piglins are the users in the tree, and Piglin treats
a kinetic weapon like a crossbow when deciding what it is holding. Both
paths read KineticWeapon.computeDamageUseDuration — the delay plus the
damage condition’s window — to know how long to hold the charge for.
The thresholds are far easier for them than for you. Every speed bar in the three conditions is multiplied by an action factor of 0.2 for anything that is not a player, against 1.0 for you: a mob charging at a fifth of your speed clears the same bar.
What a data pack can and cannot change here
The shape is data-driven; the values are not. PiercingWeapon and
KineticWeapon are ordinary data components with codecs and stream codecs,
so a data pack can describe a weapon of either kind without code — but every
built-in spear’s numbers are hard-coded (the shared ones in
Item.Properties.spear, the per-material ones where Items registers each
spear and in its ToolMaterial) rather than read from JSON, which is why the seven differ almost only
in arithmetic.
One field in KineticWeapon is not a combat number at all.
KineticWeapon.forwardMovement — 0.38 for a spear — is read only by
SpearAnimations, and only by its third-person methods: a rendering offset
living in the middle of a combat component, shipped to every client that
receives the item.
Where to look
Item.Properties.spear is one statement and is the whole definition of
the weapon — read it first, with the numbers for one material in hand. Then
the two components it hangs on: PiercingWeapon, whose
PiercingWeapon.canHitEntity filter serves both attacks, and
KineticWeapon, whose KineticWeapon.damageEntities is the charge end
to end and whose nested KineticWeapon.Condition is three fields and one
test.
The exit is worth reading as a pair: LivingEntity.stabAttack for what a
mob’s charge does to a target, then Player.stabAttack for the override a
player runs instead, which puts the cooldown curves back — and the one
condition that skips them. On the way in,
MultiPlayerGameMode.piercingAttack and
ServerboundPlayerActionPacket show how little the client sends, and
Minecraft.startAttack is where the short-circuit sits. Two doors this
page only points at: SpearUseGoal for the mob side, and
SpearAnimations, which turns out to be the only reader of a field in
the combat component.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Hunger and experience
Verified against Minecraft 26.3 · Part VIII · Two bars above the hotbar that the server owns outright: one you empty by sprinting, one you fill by mining, and neither of them is quite the number the interface draws.
The food bar and the experience bar look like the same kind of thing: a
number the server keeps and sends you. They are, and they are also the two
counters a player argues with most — the meal that does not seem to fill
you, the levels that vanish into an anvil. Both are worth following for the
same reason: neither is quite what the interface suggests. There is no
method called eat on Player or LivingEntity — eating is a walk over
the components of a stack that happens to end in FoodData.eat — and the
experience packet is change-detected on the total alone, so every
mutation that changes only your level has to poison the last-sent value or
the bar will not move.
The cast
| class | what it decides | thread |
|---|---|---|
FoodData | the food bar, saturation and exhaustion; both sides hold one, but only the server ticks it | both (FoodData.tick takes a ServerPlayer) |
FoodProperties | how much a given item is worth, as a data component | both |
Consumable | how long eating takes, what it sounds like, and what else it applies | both |
Player | the four experience fields, the enchanting seed among them, and the level curve | both |
ExperienceOrb | a value and a multiplicity, wandering toward you | both |
ServerPlayer | the change detection that turns any of this into a packet | Server |
Both halves hang off ServerPlayer.doTick, the connection-driven half of
the two-phase
tick;
the level’s entity tick touches
essentially none of it. The order inside that half matters: item use is
resolved, then Player.aiStep runs ServerPlayer.tickRegeneration — which
is the Peaceful refill, its whole body gated on that difficulty and the
natural-regeneration rule — and the
orb pickup, then FoodData.tick, and then the
change-detection block that emits the packets. So a meal eaten this tick and
a Hunger effect’s exhaustion from this tick are both visible to
FoodData.tick in the same tick, and the resulting health and food reach
the client in that tick too.
The food bar is four numbers and a file of constants
FoodData (world/food) is a value bag with no back-reference to the
player: FoodData.foodLevel (20), FoodData.saturationLevel (5.0),
FoodData.exhaustionLevel and FoodData.tickTimer. Its surface is the two
FoodData.eat overloads (one taking a FoodProperties, one taking a
nutrition and a saturation modifier), plus FoodData.addExhaustion (which caps at
40), FoodData.needsFood, FoodData.hasEnoughFood, FoodData.setFoodLevel,
FoodData.setSaturation, the two accessors, and FoodData.tick — whose
signature is FoodData.tick(ServerPlayer), server-only by type. It saves
as four loose keys in the player tag, not a sub-compound.
FoodConstants names most of the thresholds in the system —
FoodConstants.MAX_FOOD, FoodConstants.HEAL_LEVEL,
FoodConstants.HEALTH_TICK_COUNT,
FoodConstants.HEALTH_TICK_COUNT_SATURATED,
FoodConstants.EXHAUSTION_DROP, FoodConstants.EXHAUSTION_HEAL,
FoodConstants.EXHAUSTION_SPRINT, FoodConstants.EXHAUSTION_MINE,
FoodConstants.EXHAUSTION_ATTACK, FoodConstants.SPRINT_LEVEL,
FoodConstants.SATURATION_FLOOR and the saturation-quality ladder from
FoodConstants.FOOD_SATURATION_POOR to
FoodConstants.FOOD_SATURATION_SUPERNATURAL — and each is a compile-time
constant, which javac writes in wherever it is used, so a decompile of
FoodData shows the numbers themselves and this page gives them as numbers.
The file’s one method, FoodConstants.saturationByModifier, turns a food’s
saturation modifier into saturation. A few numbers have no name there at all,
the exhaustion cap of 40, the starvation floors and the Peaceful refill’s
periods among them.
Before the tick can spend exhaustion, something has to produce it, and the
economy is smaller than players assume. Walking costs nothing:
ServerPlayer.checkMovementStatistics charges the walking and the crouching
branch zero per block, the zero FoodConstants.EXHAUSTION_WALK and
FoodConstants.EXHAUSTION_CROUCH name. What does cost you is sprinting, jumping,
swimming (and walking in or under water, at the same rate), mining, attacking, the
Hunger effect, the ApplyExhaustion enchantment effect, and being hurt — the
last of which is data-driven, since DamageSource.getFoodExhaustion reads
the damage type. Player.causeFoodExhaustion charges 0.1 for a melee hit that lands,
which is the figure the sword swing and the
spear both end on. And in creative or spectator the whole
economy is disabled in one line: Player.causeFoodExhaustion returns
immediately for invulnerable abilities.
What FoodData.tick then does with that budget is one exhaustion rule and a
three-way chain, tested in the order below, first match only — and the
figure is that order, with the conditions themselves in the table under it.
flowchart TD
EX{"exhaustion<br/>above 4.0?"}
EX -- "yes" --> DRAIN["spend 4.0 — saturation first, then the bar"]
EX -- "no" --> C1
DRAIN --> C1{"1 · heal fast?"}
C1 -- "yes" --> FAST["every 10 ticks, charged back as exhaustion"]
C1 -- "no" --> C2{"2 · heal slowly?"}
C2 -- "yes" --> SLOW["every 80 ticks, for 6.0 of exhaustion"]
C2 -- "no" --> C3{"3 · starve?"}
C3 -- "yes" --> STARVE["every 80 ticks, above the difficulty's floor"]
C3 -- "no" --> RESET["none of the three — the timer resets"]
A chain, not a fan: each numbered diamond after the first is only reached because the one above it said no, which is the whole of why the three are exclusive.
| tested in this order | the condition | and then |
|---|---|---|
| 1 · heal fast | the game rule on, saturation left, hurt, and the bar at 20 | every 10 ticks: heals a sixth of the saturation it spends, and charges that much back as exhaustion |
| 2 · heal slowly | the game rule on, hurt, and 18 food or more | every 80 ticks: half a heart, for 6.0 of exhaustion |
| 3 · starve | food at zero — and not gated on the game rule | every 80 ticks: half a heart of DamageTypes.STARVE, above the difficulty’s floor |
| 4 · none of them | — | FoodData.tickTimer goes back to zero |
The order is what makes the three exclusive, and it is why a full bar with saturation left heals you up to eight times faster than a bar at eighteen: both conditions hold at twenty food, and the fast one is tested first.
The game rule is GameRules.NATURAL_HEALTH_REGENERATION, which lives in
world/level/gamerules with typed GameRule lookups (level data and
rules). The starvation hit only
lands if health is above five hearts, or above half a heart on Normal, or
unconditionally on Hard: five hearts is the floor on Easy and Peaceful, half
a heart on Normal, and death on Hard. DamageTypes.STARVE is declared with
zero exhaustion, so starving does not feed itself.
Eating is a component walk
FoodProperties (DataComponents.FOOD) is three things: nutrition,
saturation and can always eat. That is the whole of what a meal is worth.
Everything about how long it takes, what it looks like and what else it
applies is a different component on the same stack — Consumable, and using
an item owns it.
The two meet through an interface, and that is the answer to the missing
eat method. FoodProperties reaches the player by implementing
ConsumableListener,
and Consumable.onConsume walks every component of that type on the stack.
It is not the only implementation — PotionContents,
SuspiciousStewEffects and OminousBottleAmplifier implement it too, and
PotionContents is how drinking applies an effect, which is where this page
and status effects meet in one method. Two routes reach
FoodData.eat without any of that: CakeBlock.eat, and the saturation
effect, both using the overload that takes a nutrition and a saturation
modifier.
The walk itself is five hand-offs on the server and one line out to the client, and the packet leaves before any of the five have run.
sequenceDiagram
box transparent the server
participant SP as ServerPlayer
participant IStack as ItemStack
participant Cons as Consumable
participant FP as FoodProperties
participant FD as FoodData
end
box transparent the client
participant CPL as ClientPacketListener
end
SP->>SP: updateUsingItem — the zero check is server side only
SP->>CPL: ClientboundEntityEventPacket(9), before the walk starts
SP->>IStack: finishUsingItem — the item decides what finishing means
IStack->>Cons: onConsume — walks every ConsumableListener on the stack
Cons->>FP: onConsume — the food component is one such listener
FP->>FD: eat — nutrition and pre-multiplied saturation, clamped
Cons->>Cons: onConsumeEffects — server only, then consume(1)
CPL->>CPL: the same walk again, locally — the food half unguarded
SP->>FD: tick — exhaustion drain, then regen or starvation
SP->>CPL: ClientboundSetHealthPacket — the prediction is overwritten
The second arrow leaves before the server has eaten anything, and it and the health packet at the end are the figure’s only arrows to the client (the meal’s sounds go as broadcasts): the five arrows after it on the left are the walk, and the client’s one self-call is that walk run again, its food half with no side guard.
Consumable.canConsume consults Player.canEat only when the stack has
DataComponents.FOOD and the user is a player — potions and milk are
ungated — and Player.canEat itself passes for invulnerable abilities,
can always eat, or FoodData.needsFood. An item whose
Consumable.consumeTicks is zero is consumed instantly with no animation.
After the food lands, ItemStack.finishUsingItem applies
DataComponents.USE_REMAINDER and DataComponents.USE_COOLDOWN (using an
item).
The decision to finish is server-only, but the client replays the meal on
an entity event and runs FoodProperties.onConsume and its FoodData.eat
locally, with no side guard on it — which is why the hunger bar’s jump is
predicted while a chorus fruit’s teleport is not (using an
item). The prediction barely
outlives the packet that caused it when the meal moved the bar: the server
then sends ClientboundSetHealthPacket from the same tick, and ClientPacketListener.handleSetHealth overwrites food
and saturation outright, and routes health through
LocalPlayer.hurtTo, which works out the delta first so the damage flash
still plays. The client also reads its food data for three decisions of its
own: whether you may start eating at all (Player.canEat), sprinting, which is
gated on having more than six food or being able to fly, and the HUD’s
food-bar jitter, which reads saturation.
What crosses the wire for all of this is four packets of its own, beside the
sounds it broadcasts —
ClientboundEntityEventPacket for the finished meal, ClientboundSetHealthPacket
(health, food and saturation together, to that player only),
ClientboundSetExperiencePacket (progress, level and total) and
ClientboundTakeItemEntityPacket for the orb pickup animation — and what is
data-driven is DataComponents.FOOD, DataComponents.CONSUMABLE,
DataComponents.USE_EFFECTS, the consume effects a Consumable lists (typed
by Registries.CONSUME_EFFECT_TYPE), the damage types’ exhaustion and the
three game rules this page names.
Eating slowdown is a third component again — UseEffects, on every item,
which is why a meal and a drawn bow cost you exactly the same speed (using
an item). It has nothing to
do with the food: it is read while any item is held out, and a spear is the
one definition that turns it off (the spear).
The other bar, and the number it is really watching
Player.experienceLevel, Player.experienceProgress,
Player.totalExperience and Player.enchantmentSeed, plus
Player.takeXpDelay and Player.lastLevelUpTime — which exists only to
throttle the level-up sound — are the whole of it. The arithmetic is
Player.giveExperiencePoints, Player.giveExperienceLevels and
Player.getXpNeededForNextLevel — the three-segment curve with corners at
levels 15 and 30.
Where orbs come from is worth naming, because two game rules gate it:
LivingEntity.dropExperience requires the experience not to have been
consumed already, and either an always-dropper or a recent player kill with
GameRules.MOB_DROPS on and the entity’s own
LivingEntity.shouldDropExperience agreeing; a player’s own death drop is
Player.getBaseExperienceReward, seven per level capped at 100, unless
GameRules.KEEP_INVENTORY — or unless you are a spectator, which drops
nothing.
The packet that draws the bar watches one of those four fields, and it is
not the one on the bar. ServerPlayer.lastSentExp is compared against
Player.totalExperience, and ClientboundSetExperiencePacket goes out only
when the two differ. Every mutation that changes the level without changing
the total — ServerPlayer.setExperienceLevels, Player.giveExperienceLevels,
enchanting, respawn — therefore has to poison the last-sent value by hand
before the bar will move. Nothing in the game changes a level and lets the
change detection notice: ServerPlayer’s version of each of those methods
remembers to lie to it, and a new one that forgot would produce a client whose
level is stale until the next orb.
ExperienceOrb is an Entity with ExperienceOrb.DATA_VALUE synched
and ExperienceOrb.count, ExperienceOrb.age, ExperienceOrb.health and
ExperienceOrb.followingPlayer unsynched — though ExperienceOrb.age and
ExperienceOrb.followingPlayer are still mutated by the client’s own tick,
which runs the follow behaviour locally. ExperienceOrb.health is not: once
the orb is made or loaded, only ExperienceOrb.hurtServer writes it.
ExperienceOrb.awardWithDirection splits an amount into denominations via
ExperienceOrb.getExperienceValue (a fixed ladder from 2477 down to 1) and
calls ExperienceOrb.tryMergeToExisting for each; ExperienceOrb.award is
a one-line delegate to it. Merging is by count, not value: an orb
carries one value and a multiplicity. The merge candidate search picks a
random group number below ExperienceOrb.ORB_GROUPS_PER_AREA and only
merges into orbs whose id is congruent to it — which keeps same-valued orbs
in separate groups that never merge across, rather than reducing the scan.
Questions players ask
Why does a meal not seem to fill me? Because the number you cannot see is
clamped to the one you can. FoodData.eat adds nutrition to the bar and
saturation behind it, and then clamps saturation to the new food level —
so the emptier you are when you eat, the more of the meal’s saturation is
thrown away. A golden carrot carries 14.4 of it — nutrition 6 at a modifier
of 1.2, doubled by FoodConstants.saturationByModifier — and eaten on an
empty bar it leaves you at six food and six saturation, with more than half
of what it was worth discarded. The reserve that funds fast healing is
exactly what an emergency meal loses. At the other end the
meal does not happen at all: Player.canEat refuses at a full bar unless the
food is can always eat or your abilities are invulnerable.
Can my saturation sit above my food bar? Only on Peaceful with natural
regeneration on, and only by less than a point at the top:
ServerPlayer.tickRegeneration adds a whole point of saturation whenever it is
below 20, while FoodData.eat clamps saturation to the food level. Only one of
the two respects the clamp.
Why is my client’s saturation out of date? Because it is sent but not
change-detected. ClientboundSetHealthPacket carries a full float, yet the
server only notices whether saturation became zero — so your client’s
saturation does not update until health or food moves or it crosses zero, and
the one thing the HUD reads from it, whether it is zero, is the one thing sent
on time.
Why is the Standard Galactic gibberish stable until I enchant? Because
Player.onEnchantmentPerformed subtracts the level cost and re-rolls
Player.enchantmentSeed, while AnvilMenu — which also spends levels,
through Player.giveExperienceLevels — does not. A seed that loads back as
zero is re-rolled on read. Enchanting
owns what the seed is for. The levels the anvil eats and the seed the table
reads are fields on the same object, and only one of the two spenders touches
both.
Why does an orb repair my pickaxe before it reaches my bar? Because
ExperienceOrb.playerTouch runs ExperienceOrb.repairPlayerItems first —
mending, through EnchantmentEffectComponents.REPAIR_WITH_XP — and only the
remainder becomes experience; that method then calls itself with the
leftover. One orb entity can also be picked up many times: it carries a
count, decremented per absorption behind a two-tick Player.takeXpDelay,
and a player touches one orb per tick, chosen at random from those it is
touching — so at most one is absorbed every two ticks. The pickup sweep buckets orbs separately from items for exactly
that purpose.
Where to look
FoodData is short and is the food half entire — read it first, then
FoodConstants beside it for the names of most of the numbers a decompile
shows written into FoodData. Foods is where the
built-in FoodProperties values live, if you want to know what a carrot is
worth.
For the walk that reaches it, open Consumable.onConsume and follow it
into ConsumableListener: FoodProperties is one implementation of
four, and seeing the other three is what makes the missing eat method
obvious. The experience half is one method,
Player.giveExperiencePoints, plus ExperienceOrb for the
denomination ladder and the merge. Then the two packets the whole page is
about: ClientboundSetHealthPacket, which carries a number it does not
change-detect, and ClientboundSetExperiencePacket, which
change-detects a number it is not drawing.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Status effects
Verified against Minecraft 26.3 · Part VIII · Poison II is already on you: the server hurts you on a rhythm, and your client never runs a single one of the effect’s hooks — it only counts.
Poison II lands. Your health starts dropping in steps, the swirls appear,
the icon appears in the corner, and if the connection stutters the timer
beside your inventory keeps counting anyway. That last part is the whole
page. The client runs none of a MobEffect’s behaviour. It counts
durations down, advances a blend factor and spawns particles
from a list the server synched. It does read the effects it holds — jump
boost in LivingEntity.getJumpBoostPower, slow falling in
LivingEntity.getEffectiveGravity, levitation inside LivingEntity.travelInAir
(movement and
collision) — because
that is shared movement code your own player runs unguarded, your client
simulating its own player in earnest (authority). But
every attribute modifier, every pulse of damage, every regeneration tick
happens on the server behind an explicit server-side guard, and apart from
the packets an event sends — a new or changed effect, a hidden one surfacing,
a respawn or a change of dimension — the client’s copy of the duration is
corrected only when the server happens to notice, which it does by testing
whether the remaining duration divides by six hundred. For a finite effect
that is once every six hundred ticks. An infinite effect’s duration is −1,
which divides by nothing, so that test never re-sends it.
The cast
| class | what it decides | thread |
|---|---|---|
MobEffect | what the effect does, and on what rhythm — the singleton, shared by every holder | Server (the client reads its colour, blend durations, name and category, and for a tooltip its attribute templates) |
MobEffectInstance | duration, amplifier, flags, and the masked effect underneath | both |
MobEffects | the forty built-in holders | — |
LivingEntity | LivingEntity.activeEffects, the tick, and the three server-guarded hooks | both |
AttributeInstance | where an effect’s modifier lands | Server (a syncable attribute’s base and modifiers are sent to the client) |
ServerPlayer | who gets told, and how often | Server |
MobEffectUtil | the questions the rest of the game asks about effects | both |
What an effect is
MobEffect is the behaviour singleton: a category, a colour, a particle
factory, blend durations and a map of MobEffect.AttributeTemplates. Its
hooks are the interesting part, because one of them is false by default.
| hook | when it runs |
|---|---|
MobEffect.shouldApplyEffectTickThisTick | every tick, to ask whether this is a pulse — false by default |
MobEffect.applyEffectTick | on a pulse; returning false ends the effect |
MobEffect.applyInstantaneousEffect | never from the tick — only from the splash potion, the lingering cloud and the drink |
MobEffect.onEffectAdded | when it lands on an entity that did not already have it |
MobEffect.onEffectStarted | on every LivingEntity.addEffect that passes LivingEntity.canBeAffected, whether or not it changed anything |
MobEffect.onMobHurt | when the holder takes damage |
MobEffect.onMobRemoved | when the holder is killed or discarded |
The default false is why each effect has its own rhythm, and poison’s,
regeneration’s and wither’s are each one number right-shifted by the
amplifier — halved per level, floored at one tick. Poison’s is 25, so Poison I pulses every 25 ticks and the Poison II
of this page’s scenario every 12; regeneration’s is 50, wither’s 40, and
hunger’s override skips the arithmetic and pulses every tick.
Attribute modifiers go on as AttributeInstance.addPermanentModifier with
an amount linear in amplifier + 1, computed by
MobEffect.AttributeTemplate.create (attributes).
MobEffectInstance is the per-entity half: duration, amplifier, the
ambient, visible and show-icon flags, a private blend state, and
MobEffectInstance.hiddenEffect.
That last one is a stack, and it is where a weaker effect goes when a
stronger but shorter one lands on top of it. MobEffectInstance.update builds the chain,
so Strength I with nine minutes left survives underneath a Strength II that
has thirty seconds, and surfaces again through
MobEffectInstance.downgradeToHiddenEffect when the stronger one expires.
Both of the instance’s codecs are recursive, so the save file and the wire
format are each capable of carrying the whole chain.
The wire does not. ClientboundUpdateMobEffectPacket never uses that
stream codec at all: it writes an entity id, a MobEffect holder, an
amplifier, a duration and a flags byte by hand. So the client rebuilds an
instance with nothing under it, and learns about the masked one only when the
surfacing on the server triggers a fresh packet — which is why a weaker
effect can appear from nowhere in your list the instant a stronger one runs
out.
MobEffectInstance.INFINITE_DURATION is −1, and
MobEffectInstance.compareTo is what orders both lists of effects a player
sees — ambient last, infinite next, then by remaining duration, then by
colour, except that two ambient effects, or two with more than 32,147 ticks
left, skip the infinite and duration steps. The two surfaces sort it in opposite
directions — the HUD reversed, the inventory list not — and the HUD also lays
its icons out from the right, so the two end up in the same order: left to
right along the top of the screen, top to bottom beside your inventory.
The blend state beside those flags is a pure render quantity: it ticks
only on the client, is never saved and never sent, and only
MobEffects.NAUSEA and MobEffects.DARKNESS declare blend durations to use
it. The wire carries one bit for it, set only when an effect is first
added — an update clears the bit, and a client that did not have the effect
skips the blend, while one that did carries its blend over
(MobEffectInstance.copyBlendState). That is why Nausea swims in when you eat
a pufferfish and simply carries on when you eat a second.
LivingEntity.canBeAffected is the veto, and it consults three entity tags
as well as the effect itself.
MobEffectCategory is the third small vocabulary word, and it is three
constants — MobEffectCategory.BENEFICIAL, MobEffectCategory.HARMFUL and
MobEffectCategory.NEUTRAL — carrying nothing but a ChatFormatting. Two
things read it. A potion’s tooltip takes each line’s colour from
MobEffectCategory.getTooltipFormatting, which is why neutral prints blue
like a benefit; and the HUD asks MobEffect.isBeneficial, which is true of
MobEffectCategory.BENEFICIAL alone, to decide which of its two rows an icon
goes in — so a neutral effect sits on the bottom row with the harmful ones
while its tooltip is coloured with the good ones.
Those three are the vocabulary. The registry that uses them,
MobEffects, is forty entries, and every one is a Holder<MobEffect>,
not a bare MobEffect — including MobEffects.BREATH_OF_THE_NAUTILUS. Some
point at attributes a reader would not expect: MobEffects.JUMP_BOOST
modifies Attributes.SAFE_FALL_DISTANCE, and MobEffects.INVISIBILITY
modifies Attributes.WAYPOINT_TRANSMIT_RANGE.
Behind all of it is a fourth family the page’s own hook rests on:
world/effect holds a small MobEffect subclass for each effect that needs
behaviour — PoisonMobEffect, RegenerationMobEffect, WitherMobEffect,
HungerMobEffect, AbsorptionMobEffect and eight more, one of them serving
both instant health and instant damage — with InstantaneousMobEffect the
base of those two and saturation, which act on every tick an instance of them
lasts. Everything else in MobEffects is a plain MobEffect with no code,
eleven of them carrying an attribute template. The rhythms above are those subclasses overriding two methods each; the
family is the shape, and no one member is worth a lecture.
On the entity itself: LivingEntity.activeEffects (a plain unordered map),
LivingEntity.effectsDirty, and two synched values —
LivingEntity.DATA_EFFECT_PARTICLES, which is a list of
ParticleOptions rather than a packed colour, and
LivingEntity.DATA_EFFECT_AMBIENCE_ID. The dirty flag has exactly one
reader, and it is not this page’s tick: LivingEntity.updateDirtyEffects
drains it into the invisibility and glowing flags, the swirl list and the
ambience value from inside LivingEntity.updateDataBeforeSync, which the
server’s entity-sync pass calls
before it looks at what changed (synched entity
data).
An effect that expired this tick therefore opens its own gate.
That particle list is also nearly all a watcher gets, and it is
deliberately hard to see. The default particle factory bakes ambience into
the ParticleOptions itself — alpha 38 of 255 instead of opaque — so an
ambient effect really does synch a different, fainter particle; and the
client spawns one particle from the list on a one-in-n roll whose n is
four, fifteen if the entity is invisible, and multiplied by five again when
every visible effect on it is ambient, which is what
LivingEntity.DATA_EFFECT_AMBIENCE_ID records. An invisible, wholly ambient
entity is rolling one in seventy-five; a visible one in the same state,
twenty.
MobEffectUtil is the shared
question-asking surface: MobEffectUtil.hasDigSpeed,
MobEffectUtil.hasWaterBreathing,
MobEffectUtil.shouldEffectsRefillAirsupply,
MobEffectUtil.addEffectToPlayersAround and the duration formatter the
inventory screen uses.
Poison II counting down on two machines
Effects are ticked from LivingEntity.tickEffects, the last call
LivingEntity.baseTick makes before it copies this tick’s rotations into
last tick’s — which for a player means inside
ServerPlayer.doTick, the connection-driven half of the two-phase
tick,
not the level’s entity tick.
On the server, that tick is three objects and two packets:
sequenceDiagram
participant SP as ServerPlayer
participant MEI as MobEffectInstance
participant ME as MobEffect
participant Wire as the network
SP->>MEI: update, when one is already there — a longer, weaker one goes under
SP->>ME: addAttributeModifiers — LivingEntity.onEffectAdded, or onEffectUpdated on a change
SP->>Wire: ClientboundUpdateMobEffect<br/>Packet
rect rgba(0, 0, 0, 0.04)
Note over SP,Wire: every server tick after that
SP->>MEI: tickServer, from LivingEntity.tickEffects
MEI->>ME: shouldApplyEffectTickThisTick — 25 halved per level, for poison
MEI->>ME: applyEffectTick — the pulse, and false here ends the effect
SP->>Wire: ClientboundUpdateMobEffect<br/>Packet, when the duration divides by 600
end
Two arrows leave for the client and the second is the periodic correction: inside the band the duration is the server’s, and unless something changes the client hears about it only on the ticks where it divides by six hundred.
The client’s half of the same scenario is a different set of objects, and the first thing to notice about it is which class is not in it:
sequenceDiagram
participant Wire as the network
participant CPL as ClientPacketListener
participant LE as LivingEntity
participant MEI as MobEffectInstance
Wire->>CPL: ClientboundUpdateMobEffect<br/>Packet
CPL->>LE: forceAddEffect — a fresh instance, with nothing under it
rect rgba(0, 0, 0, 0.04)
Note over Wire,MEI: every client tick after that
LE->>MEI: tickClient — count down and advance the blend
end
There is no MobEffect lane here, and that absence is the page: the client
holds the instance and counts it, and never once asks the singleton what the
effect does.
The client branch of LivingEntity.tickEffects never calls
MobEffect.applyEffectTick and never touches an attribute. It does not even
remove an expired effect: it keeps a zero-duration instance until told
otherwise. A client runs an effect’s hooks only when shared code calls
LivingEntity.addEffect on it without a guard — a meal it replays (a
suspicious stew, an ominous bottle), a turtle helmet worn out of water, a
cookie fed to a parrot — and none of the hooks those reach does anything there.
What survives a save, and why the modifiers do
LivingEntity.addAdditionalSaveData writes the active list under
active_effects through MobEffectInstance.CODEC, and only when the list is
non-empty; LivingEntity.readAdditionalSaveData reads it back, clears
LivingEntity.activeEffects and puts each instance straight in. Nothing on
that path runs LivingEntity.onEffectAdded, so the hook that would install
the attribute modifier never fires on load — and the effect’s Strength still
works, because the modifier was written into the AttributeInstance as a
permanent one and was itself saved and restored (attributes).
The two halves of an effect are persisted separately and reunited by
convention rather than by code, which is why the codec that can carry a
hidden-effect chain matters at all.
Questions players ask
Why does my duration sometimes jump? Because it was wrong and got
corrected. The client’s duration is a local countdown with nothing keeping it
honest between corrections, and the periodic one is a divisibility test:
LivingEntity.tickEffects calls LivingEntity.onEffectUpdated whenever the
remaining duration divides by six hundred. The re-send only ever reaches the
affected player or a player riding them — and never at all for an infinite
effect, whose −1 divides by nothing.
Why can I not see how long a mob’s effect has left? Because you were
never told. A client watching a mob it is not riding receives no
MobEffectInstance at all — only what the effects show: the synched particle
list and flags, and any syncable attribute an effect has changed — which is
why other entities have swirls and no numbers.
Does an effect pulse on its own clock or the world’s? Never the world’s.
MobEffectInstance.tickServer counts an infinite-duration effect’s pulses off
the entity’s own age and a finite one off its own countdown.
What happens when one effect adds another? The rest of that tick’s
effects are silently skipped. LivingEntity.tickEffects catches a
concurrent-modification error and drops it, so an effect that adds or removes
another quietly aborts the loop it was in.
Where does the effect come from in the first place? Not from this page.
Everything above starts at the moment an instance exists on a
LivingEntity; the machinery that puts one there — the use timer, the
finish, the replay — is using an
item; the components that
ride on it, PotionContents and SuspiciousStewEffects among them, belong to
hunger and experience,
and the consume effects, ApplyStatusEffectsConsumeEffect among them, to
using an
item.
What crosses the wire from here on is ClientboundUpdateMobEffectPacket and
ClientboundRemoveMobEffectPacket for the effects you hold,
LivingEntity.DATA_EFFECT_PARTICLES through synched entity
data for everyone else’s swirls, and
ClientboundUpdateAttributesPacket for an effect on a syncable attribute such
as speed. The effects themselves are code, registered into
BuiltInRegistries.MOB_EFFECT by MobEffects with no JSON behind them; what
is data-driven is some of the ways they land.
Where to look
LivingEntity.tickEffects is the page in one method: the server branch
and the client branch sit side by side in it, and the asymmetry is visible at
a glance. Read MobEffectInstance next, for the hidden-effect chain and
the two codecs that can carry it, then MobEffect for the seven hooks
and the one that is false by default. PoisonMobEffect is a
short subclass and is the cheapest way to see what an override
overrides; world/effect has a dozen more of the same shape.
MobEffects is worth one pass for the two attribute targets nobody
expects, and MobEffectCategory takes a minute and explains why a
neutral effect is coloured like a benefit and filed with the harmful ones.
When you want to know what the client is allowed to know,
ClientboundUpdateMobEffectPacket is five fields written by hand and is
the whole answer. Two doors this page only points at:
LivingEntity.canBeAffected, the veto and its three entity tags, and
MobEffectUtil, which is where the rest of the game asks its questions.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
IX · Networking
Verified against Minecraft 26.3 · Part IX · One socket, four languages, and everything the two halves of the game say to each other across it.
Parts II to IV had a single machine to describe, and Parts V to VIII compared two. This one is about what passes between them. A player meets the seam as a set of symptoms — the Connection lost screen with a reason string on it, the world that arrives in a pause and then a flood, the chest whose contents appear only when it is opened, the chat line that turns red and takes every line after it with it — and each of those can be a decision rather than a fault. Singleplayer runs the same wire, an integrated server on its own threads talking to the client through an in-memory channel, so the split is not a multiplayer feature bolted on the side: it is the shape of the program, and the wire is not a pipe between two halves of one machine but a border between two machines, and the server treats what arrives from a client as a claim rather than a fact.
The shape of the part
Part IX is the wire three times, and two things it carries. The first three lectures are all about the wire and are ordered by what they describe it as: bytes on a socket, then the values those bytes are, then the four languages the socket speaks in turn — a connection changes language as it goes from handshaking to playing, and the last of the three is where that happens. After those three, the part is mostly about what the wire carries rather than the wire. The last two both sit inside the play language: everything the server chooses to tell a client about the world, and the one protocol in the book in which what a player sends is signed, so that the server and the players it reaches can check it.
flowchart TD
subgraph W["the wire, three times"]
TC["1 · The connection — bytes to a handler call, and back"]
PSC["2 · Packets and stream codecs — what crosses the wire"]
PP["3 · Protocol phases — four languages over one socket"]
end
subgraph A["inside the play phase"]
WCT["4 · What the client is told — the server's choosing"]
CS["5 · Chat and signing — proving who sent a message"]
end
TC -- "two threads" --> PSC
PSC -- "a codec table per phase" --> PP
W ~~~ A
The five lectures, numbered to the watch order: the top box is the wire itself, the bottom box is the one language the last two live inside, and an arrow is what the lecture before it settles. Nothing points into the bottom box: its title says where the last two live, and neither needs the other or the lecture above it.
The spine is the first two: read them together, because the second is the second half of the first. The last two could hardly differ more — telling a client where the mobs are and sending it chunks is most of what a connection ever carries, while chat is a handful of packets a minute with a wholly disproportionate amount of cryptography on it.
Before you start
Part I’s anatomy first, for the two-loops figure. The client’s frame loop and the server’s tick loop are different clocks, and the client drains its inbound packets once per frame, not once per tick. That one fact is behind most of what looks like network jitter, and the first and fourth lectures lean on it.
Part III next, and not optionally, because two of this part’s claims are really facts about somebody else’s loop and Part IX states the consequence rather than re-teaching the loop: the server tick owns what happens after every level has ticked, and the level tick owns the phase in which broadcasts go out — before the entity phase, which is why one broadcast carries the block changes made so far this tick but the previous tick’s entity movement.
Then Part II for two objects this part assumes whole: codecs, because a packet codec is the same idea specialised to a byte buffer, and components, because what a chat line displays is one. And Part VI’s authority, which is the premise under lecture four: which side of the wire is allowed to decide where a thing is, and therefore what the other side may be told late.
Nothing here waits on Part X. The client’s half of the story is the deeper version of the first item above, and where its arithmetic would matter these pages state the consequence and link forward to it.
Watch in this order
- The connection — you swing at a pig, and some milliseconds later a method runs on the server’s game thread. The lecture with the there-and-back diagram, and then the whole life of the channel that carried it: two threads, two codec layers, one hop.
- Packets and stream codecs — the second half of the same lecture. What the thing crossing the wire is, now that you have watched it travel, and why its number is written down nowhere.
- Protocol phases — a login, from clicking a server in the list to standing in the world: four languages over one socket, and the surprising thing about when in that sequence a player becomes an object on the server.
- What the client is told — a creeper walks into view. Not a trace but a policy: three gates a change passes before it becomes a packet, and the things the server decides never to say.
- Chat and signing — the part’s closer, and the one system in the book whose messages are signed by the player who wrote them. What each check catches, and whether it kills the message, the chain, or the connection.
One and two are the pair to keep together; four and five can be watched in either order, and neither strictly needs three.
Where the part stops
Part IX’s packages hold 490 files and 38,711 lines, and
the count flatters it, because this part owns the wire, not everything in
network/: Component and the classes around it belong to Part
II, much of
client/multiplayer is the receiving end, which is Part X’s, and the largest
block is the packet classes, catalogued in packets
rather than narrated one by one.
Three systems in those packages this part names and does not teach.
Player reporting is out of scope on what this book
skips. The server list
screen is one more screen for GUI and
screens; only how a typed address becomes a
socket
belongs here. The boss-bar feed — ClientboundBossEventPacket, one packet with six
operations — belongs to its model, scores, teams and stored
data,
and to the HUD. Past those,
8% of the part’s lines are named on no page in the book.
Outward, what the client does with what it is told is Part X’s, and how a
ServerPlayer comes to exist at all is Part
III’s. This
part stops at the packet that says so.
Reference this part uses
Packets above all — the catalogue this part narrates, and the page to keep open beside every lecture in it. Then the threads, which names the Netty event loop and the two game threads and lists the nine handlers on the client’s play listener that never hop. Behind those: registries for what crosses during configuration, components where a packet carries a stack, level data and rules for the values a server sends on request rather than on change, and diagram lanes.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
The connection
Verified against Minecraft 26.3 · Part IX · you swing at a pig: one packet up and its answer out to the players watching, from a value on one thread to bytes on a wire to a method call on another — and then the whole life of the channel that carried it.
You swing. ServerboundPunchPacket — an immutable value holding nothing at
all, not even which hand — is handed to Connection.send on the client’s Render
thread, and some milliseconds later
ServerGamePacketListenerImpl.handlePunch runs on the server’s game thread
with that value as its argument. What comes back — unless your last swing is still in its first half, when
nothing does — is ClientboundSwingAnimationPacket, and it goes to every
player tracking you except you: your own arm was already swinging, because the client moved it the
moment you clicked. Now close the server list and open a singleplayer world.
Every sentence above is still true. The integrated server is another thread
in the same process, and the client reaches it through a real Netty channel
with a real PacketEncoder and a real PacketDecoder in it:
singleplayer serialises every packet to bytes and parses them back again.
There is no local shortcut — the same encoder runs, the same decoder runs,
and what reaches the server is what the decoder made of the bytes.
That crossing is the first half of this page. The second half is the channel it travelled on, from the first listener installed on a raw socket to the fault that closes it: one connection’s whole life, which is more than one packet’s journey and is where everything surprising about the transport lives.
The cast
| class | what it decides | thread |
|---|---|---|
Connection | the channel, the current PacketListener, and when a fault kills the link | Netty event loop, plus its tick, its sends and its flushes from a game thread |
PacketEncoder | one packet becomes the bytes of one frame, or is skipped | the sender’s Netty event loop |
PacketDecoder | one frame becomes one packet, and a terminal one dismantles the codec | the receiver’s Netty event loop |
PacketProcessor | the queue that carries a decoded packet to a game thread | filled from Netty, drained by its owner |
PacketListener | whether this packet should be handled at all — asked twice | both |
TickablePacketListener | the only way a listener gets time when no packet has arrived | a game thread |
ServerConnectionListener | the server’s accept side, and which connections get ticked | Netty for the accept, Server for the tick |
EventLoopGroupHolder | NIO, Epoll, KQueue or in-memory, and the threads that run them | any |
One packet there, and one back
sequenceDiagram
box transparent your client
participant CPL as ClientPacketListener
participant PEnc as PacketEncoder
end
box transparent the server
participant PDec as PacketDecoder
participant Conn as Connection
participant SGPL as ServerGamePacket<br/>ListenerImpl
end
box transparent a player watching you
participant RCPL as ClientPacketListener
end
Note over CPL: Render thread — a value, not yet any bytes
Note over CPL,PEnc: Connection.send, then Connection.sendPacket joins the client's Netty event loop
CPL->>PEnc: write and flush — no packet queue, Netty buffers
PEnc->>PDec: one whole frame: a VarInt id, and no fields at all
Note over PDec,SGPL: the server's Netty event loop
PDec->>Conn: channelRead0, at the tail of the pipeline
Conn->>SGPL: shouldHandleMessage, then Packet.handle
SGPL->>SGPL: ensureRunningOnSameThread<br/>queues the pair and aborts
rect rgba(0, 0, 0, 0.04)
Note over PDec,SGPL: Server thread — the PacketProcessor drain, before the tick
SGPL->>SGPL: shouldHandleMessage again, then the handler from the top
SGPL->>RCPL: the swing, through each watcher's own Connection, flushed at once
end
Note over RCPL: their client's Netty event loop
RCPL->>RCPL: ensureRunningOnSameThread<br/>queues the pair and aborts
rect rgba(0, 0, 0, 0.04)
Note over RCPL: Render thread — the drain, once per frame
RCPL->>RCPL: the handler from the top, by the next frame
end
The punch and its answer, one machine to a box: the server’s answer goes to
the players watching you and never back to you, and the one arrow into their
client folds away its encoding, its decoding and the Connection at each end of the
watcher’s link. Watch for each handler body being entered twice.
Six things in it are worth stopping on — the framing, the direct call, the hop, the drain’s own tick phase, the second question the drain asks, and where an error on re-dispatch goes.
Framing is separate from decoding. Varint21FrameDecoder reads at most
three length bytes, refuses anything wider and refuses a zero length, and
emits exactly one frame or nothing at all. Everything downstream of it may
assume it is looking at one whole packet, which is why the codec layer never
has to handle a half-arrived value.
Connection.channelRead0 calls Packet.handle directly, on the Netty
thread. There is no automatic hop. PacketListener.shouldHandleMessage is
consulted first — which is how a listener being torn down ignores what is
still arriving — and Connection.receivedPackets counts only the packets that
pass it. Three other outcomes live in that method: a packet arriving before
any listener is set is an illegal state; a packet whose listener does not
implement the packet’s listener interface is a failed class cast and an
invalid_packet kick; and a rejected
schedule — the server’s PacketProcessor closed because it is shutting down —
becomes a server_shutdown kick.
The hop is the handler’s own first line.
PacketUtils.ensureRunningOnSameThread asks the PacketProcessor whether
this is the right thread; if it is not, it enqueues the listener-and-packet
pair and throws the singleton RunningOnDifferentThreadException, a stackless
exception that Connection.channelRead0 catches and drops on the floor. The
handler body then runs again from the top when the queue is drained, which
is why a handler method does nothing observable before that line unless it
means to — the client’s transfer handler raises its transferring flag first, so
that ClientCommonPacketListenerImpl.shouldHandleMessage already sees it. A handler
that touches no game state — the pong bookkeeping, the chunk-batch clock, the
keep-alive answer — simply omits it and runs on Netty; the client’s play
listener has nine, listed in
threads.
An unknown custom payload never reaches a game thread either, though its
handler has the line: it decodes as a DiscardedPayload, which
ClientCommonPacketListenerImpl drops on the Netty thread before the hop it
takes for every other payload.
The drain has a tick phase of its own, and the two sides do not schedule it alike. The server drains before the tick proper, so every packet that arrived since last time enters the world at one point (the server tick); the client drains once per frame, not once per tick, because its tick is a sub-step of the frame loop (two loops and a wire between them, and the client loop for the arithmetic). The consequence for a packet is that its handling latency on the client is a frame, not a tick — and that packet handling and the tasks posted to a game thread are two different queues drained at two different moments on both sides.
The drain re-asks the same question. PacketProcessor.ListenerAndPacket
calls PacketListener.shouldHandleMessage a second time before dispatching,
and logs and drops if the answer has changed. That is the gate that matters:
a packet can wait between the two checks, and a disconnect arriving in between
must not be able to run its handler.
Errors on re-dispatch go somewhere else entirely. The drain catches
exceptions only — a bare out-of-memory error is not caught at all — and routes
what it does catch to PacketListener.onPacketError. The interface’s own
default raises a reported crash, and no listener a drained packet reaches
uses it: every serverbound listener inherits ServerPacketListener’s
override, which logs and returns (the server
tick owns
what the server then does with the exception), and
ClientCommonPacketListenerImpl overrides it the other way, writing a
disconnection report and hanging up. The one special case is a
ReportedException caused by an out-of-memory error: that is rethrown, but
not untouched.
PacketUtils.makeReportedException delegates both steps to
PacketUtils.fillCrashReport, which first decorates the report with an
Incoming Packet category naming the type and its terminal and skippable
flags, then lets the listener add its own detail.
The pipeline, in both directions
Two directions through one list of handlers. Inbound runs head to tail; outbound runs tail to head, and the last column below is that second reading — six of the nine have an outbound mirror and the other three are inbound-only. Handlers in italics are added later, if at all.
| inbound order | handler | added by | its outbound mirror |
|---|---|---|---|
| 1 | "timeout" — a read timeout of thirty seconds | the connect or accept site, before serialization | — |
| 2 | "legacy_query" — LegacyQueryHandler | ServerConnectionListener.startTcpServerListener only, and only if the server replies to status; removes itself on the first modern byte | — |
| 3 | "decrypt" — CipherDecoder | Connection.setEncryptionKey | "encrypt", last out |
| 4 | "splitter" — Varint21FrameDecoder | Connection.configureSerialization | "prepender" — Varint21LengthFieldPrepender, its exact inverse |
| 5 | "decompress" — CompressionDecoder | Connection.setupCompression, inserted directly after "splitter" | "compress" |
| 6 | an unnamed flow-control handler | Connection.configureSerialization | — |
| 7 | "decoder" or "inbound_config" | Connection.configureSerialization | "encoder" or "outbound_config" |
| 8 | "bundler" — PacketBundlePacker | Connection.setupInboundProtocol, only for a protocol with a bundle (packets and stream codecs) | "unbundler" — PacketBundleUnpacker |
| 9 | "packet_handler" — the Connection itself | Connection.configurePacketHandler | itself, and then "hackfix" |
So outbound, from the game outwards, is that column read upwards:
"packet_handler", then "hackfix" — an anonymous pass-through that
Connection.configurePacketHandler adds immediately before it, whose write
method does nothing but call its superclass — then the unbundler, the encoder,
the compressor, the prepender, the cipher, and the socket.
Which side gets a live codec at birth is decided by direction. The end that
will receive the handshake — the server — is built with a real "decoder"
and a dead "outbound_config" placeholder; the end that will send it gets a
real "encoder" and a dead "inbound_config". Only the live one is built
from Connection.INITIAL_PROTOCOL, which is HandshakeProtocols.SERVERBOUND —
one of the nine per-phase codec tables packets and stream
codecs builds.
The placeholder is an UnconfiguredPipelineHandler.Inbound or
UnconfiguredPipelineHandler.Outbound holding no protocol at all, which is the
entire point of it.
HandlerNames holds most of those names as constants, the local pipeline’s
latency among them, and none for hackfix.
The threads underneath it
EventLoopGroupHolder owns the event-loop groups: four
instances behind two accessors, EventLoopGroupHolder.local for the in-memory
channel and EventLoopGroupHolder.remote, which tries KQueue and then Epoll
only if the native-transport flag is set and otherwise goes straight to
NIO. That flag is the client’s option and the server’s use-native-transport
property, so switching it off really does change transport rather than hint at it.
The class lives in server/network and the client uses it too: ConnectScreen,
RealmsConnect and the multiplayer screen’s ServerSelectionList — whose saved-server rows are
the ServerData that ServerList keeps on disk — ask for a group, and the list
hands the one it got to ServerStatusPinger, which never asks for its own.
What the client dials is not what the player typed
One package,
client/multiplayer/resolver, stands between the two and nothing else in the
book uses it: ServerAddress.parseString splits host from port,
ServerNameResolver resolves it, ServerRedirectHandler looks up the
_minecraft._tcp SRV record that lets a server answer on a port nobody
types, AddressCheck asks the account service whether the address is blocked,
and the answer is a ResolvedServerAddress. LegacyServerPinger is the client
half of the "legacy_query" row above, for listing a pre-1.7 server.
Singleplayer runs the same pipeline
Connection.configureInMemoryPipeline builds the local variant, and the
differences are smaller than almost anyone assumes.
"splitter"and"prepender"becomeLocalFrameDecoderandLocalFrameEncoder, which do nothing butHiddenByteBuf.unpackandHiddenByteBuf.pack— no length prefix, because the buffer never becomes a byte stream.- No read timeout on either side. What that costs the singleplayer host is the keep-alive section’s, below.
- No legacy query handler, and never a cipher or a compression handler —
though by two different mechanisms. Compression is refused at the
installation sites, which both test
Connection.isMemoryConnection. Encryption is not: neitherConnection.setEncryptionKeynor either side’s key handler asks. What prevents it sits one gate further up, inServerLoginPacketListenerImpl, where the decision to ask for encryption requires authentication and a non-memory connection, soClientboundHelloPacketis never sent and the ciphers are never reached. - Everything else is identical, with one debug exception.
PacketEncoderandPacketDecoderare still there, still running the same stream codecs, and singleplayer pays the full serialisation cost; the only handler that exists only on the local pipeline isServerConnectionListener.LatencySimulator, installed whenSharedConstants.DEBUG_FAKE_LATENCY_MSis positive.
The integrated server binds its channel through EventLoopGroupHolder.local
and ServerConnectionListener.startMemoryChannel hands back an address that
Connection.connectToLocalServer dials.
Sending, and why nothing queues on the way out
Outbound is the mirror image and shorter. Connection.send reaches
Connection.sendPacket, which checks whether the calling thread is already
the channel’s event loop and, if not, schedules the write onto it — so a
packet sent from the game thread crosses the same boundary an inbound packet
does, just without a queue of its own. Connection.doSendPacket then chooses
between a write and a write-and-flush, and between a real future and Netty’s
void promise; Connection.flushChannel performs the same hop for a bare flush.
PacketSendListener is the callback that rides along.
PacketSendListener.thenRun runs something once the packet is really on the
wire — which is how compression and the client’s encryption are installed
after the packet that announced them, and how a disconnect waits for its own
kick message to leave. PacketSendListener.exceptionallySend sends a fallback
packet when the write fails. Both run on the event loop, which is why
“disconnect after sending” is not a game-thread operation.
Why a tick’s packets leave in two writes, not fifty
The server does not flush per packet: ServerCommonPacketListenerImpl.send
turns a send into a write with no flush while
ServerCommonPacketListenerImpl.suspendFlushing has set the flag. The one
thing about that bracket which is a fact about the channel rather than
about the tick is that the flag is tested together with the thread check, so
it is honoured only for a caller on the connection’s owning game thread and
anything sent from another thread flushes on its own regardless. Which two
moments empty the buffer, and in what order, belongs to the server
tick — the
bracket opens at the top of MinecraftServer.tickChildren, which is why the
packet drain that runs before it is outside the bracket entirely.
Connection.tick, the tick a game thread gives the connection
Connection.tick is also the only place a listener gets time on a game thread
without a packet having arrived. It drains Connection.pendingActions (the
one queue Connection owns, holding closures rather than packets, and mattering
only in the window before the channel exists); ticks the listener if it is a
TickablePacketListener; calls Connection.handleDisconnection if the channel
has died; flushes; every twentieth tick runs Connection.tickSecond, which
rolls the packet-rate averages; and last, samples bandwidth. Note the order:
the flush is in the middle, and the disconnect check happens before it.
The early phases have no hop at all, and a tick is what advances them.
None of ServerHandshakePacketListenerImpl,
ServerStatusPacketListenerImpl, ServerLoginPacketListenerImpl or the
client’s ClientHandshakePacketListenerImpl contains a single
PacketUtils.ensureRunningOnSameThread call, so in those phases every protocol
swap a handler makes and the encryption setup happen on the event loop. What runs on a
game thread is this method: ServerLoginPacketListenerImpl is a
TickablePacketListener, and its tick is what performs the ban and whitelist
checks, switches compression on, and sends the terminal packet that ends the
phase. Login is a three-thread state machine, and protocol
phases walks it.
Its callers differ by side. The server has one,
MinecraftServer.tickConnection, which walks ServerConnectionListener.tick.
The client has six, because it has more than one connection and more than one
thing that waits on one: MultiPlayerGameMode.tick ticks the play
connection; ConnectScreen, RealmsConnect and ServerReconfigScreen tick
the one they are waiting on; Minecraft.tick ticks
Minecraft.pendingConnection, the singleplayer world’s until its level
arrives; and ServerStatusPinger ticks the connections it opened to ping the
servers in the list. Note the clock: all six run at tick rate, not at the frame
rate the drain above runs on.
A throw out of Connection.tick, and why the channel decides what it costs
ServerConnectionListener.tick catches an exception out of Connection.tick
(an error passes through) and kicks that client with “Internal server error” — unless
Connection.isMemoryConnection, in which case the same catch rethrows it as a
fresh reported crash named “Ticking memory connection” and takes the
integrated server down. One catch, two branches, and the branch is the channel
rather than the fault: the same bug that costs a multiplayer client its seat
costs a singleplayer player their world.
A phase change is a message written down the pipeline
Connection does not know which phase it is in. There is no protocol
field and no getter; the answer is distributed between the two codec handlers
currently in the pipeline and the listener object, and every place
Connection names a ConnectionProtocol is a comparison rather than a
reading — Connection.validateListener checks a new listener against the
protocol it is being installed for, Connection.setupOutboundProtocol asks
only whether this is login, and the handshake entry point asks only whether
the listener is the initial one. That is what makes the next paragraph
possible.
The codecs are swapped by writing through the pipeline, not by editing it
from outside. Connection.setupInboundProtocol validates that the new listener’s
direction and phase match the ProtocolInfo, assigns
Connection.packetListener, and then builds an
UnconfiguredPipelineHandler.InboundConfigurationTask — a closure that will
replace the current handler with a new PacketDecoder and turn auto-read
back on, optionally adding "bundler" after it. Auto-read is Netty’s own
switch for whether a channel keeps pulling bytes off the socket without being
asked; turning it off is how the game stops delivery in the middle of a batch
while it swaps codecs, and it is the mechanism the rest of this section
turns on. That task is written down the
channel, where UnconfiguredPipelineHandler.Inbound recognises it and runs it
with the right context, and Connection.syncAfterConfigurationChange blocks
the caller until it completes. Connection.setupOutboundProtocol is the mirror
image, and also records whether the new outbound protocol is the login one, for
the benefit of the disconnect path.
What a terminal packet does to the codecs
Returning to the unconfigured state is automatic, and it is asymmetric.
ProtocolSwapHandler.handleInboundTerminalPacket fires when a packet whose
Packet.isTerminal is true passes through PacketDecoder: it turns auto-read
off, inserts a fresh UnconfiguredPipelineHandler.Inbound under the name
"inbound_config", and removes the decoder.
ProtocolSwapHandler.handleOutboundTerminalPacket does the equivalent on the
encoder side — but there is no incoming flow to stop, so it leaves auto-read
alone and simply puts an UnconfiguredPipelineHandler.Outbound in the
encoder’s place. The bundler and unbundler remove themselves on the same
signal, and PacketBundlePacker treats a terminal packet arriving inside a
bundle as a decode error rather than a swap.
So a phase change reads: terminal packet, codecs self-destruct and inbound
reads stop, the terminal packet’s handler installs the new protocol (on the
event loop in the early phases and in the server’s return to configuration, on a
game thread otherwise), a configuration task
travels the pipeline in order with the byte stream, reads resume. The unnamed
flow-control handler between "splitter" and the decoder is what makes
turning auto-read off stop delivery mid-batch. Which phases exist,
and what ends each of them, is protocol phases.
The first listener, and who is allowed to install it
The server’s first listener is installed by
Connection.setListenerForServerboundHandshake, which refuses if one already
exists and refuses on a connection that is not receiving serverbound traffic in
the handshake protocol — so it is server-side by construction. The client has no
equivalent: its first listener arrives with the pair of protocols that
Connection.initiateServerboundConnection installs around
ClientIntentionPacket, and Connection.initiateServerboundStatusConnection is
the same code with a different intent.
Compression and encryption arrive behind the packet that announces them
Compression is Connection.setupCompression, which inserts
CompressionDecoder after "splitter" and CompressionEncoder after
"prepender", or re-thresholds them if they already exist; a negative
threshold removes both. The server turns it on during login, sending
ClientboundLoginCompressionPacket with a send-listener that installs the
handlers only after that packet is on the wire, and the client installs its
own side when it handles the packet. Both sides skip it entirely on a memory
connection. The asymmetry worth knowing is that the server validates that a
compressed frame really was above the threshold and under the decompressed
ceiling, and the client checks neither; the ceilings are in packets and
stream codecs.
Encryption is Connection.setEncryptionKey, which inserts CipherDecoder
before "splitter" and CipherEncoder before "prepender" — so decryption is
the first thing that happens to inbound bytes and encryption the last thing
before outbound bytes leave. Neither is ever removed. The server installs its
ciphers synchronously the moment it handles the key packet; the client attaches
its own to the send of that packet, so the key packet itself goes out in the
clear and everything after it does not.
How a connection dies
Connection.exceptionCaught is the funnel, and it has four outcomes.
- A
SkipPacketExceptionis logged and swallowed: the codec layer has already decided this one packet may be dropped, and the connection survives. It is the one marker that does not end the connection — an empty interface, implemented bySkipPacketDecoderExceptionandSkipPacketEncoderException, one per direction. - A timeout — the thirty-second read timeout expiring — disconnects with disconnect.timeout, the “Timed out” a player sees.
- Any other fault, the first time: the listener is asked for a
DisconnectionDetailsthroughPacketListener.createDisconnectionInfo; if this end is the one sending clientbound traffic it tries to tell the peer why, with eitherClientboundLoginDisconnectPacketorClientboundDisconnectPacketdepending onConnection.sendLoginDisconnect, and disconnects once that packet has gone.Connection.setReadOnlyfollows on both branches — on the sending one as soon as the write is handed over, without waiting for it to complete. - Any other fault, the second time — a fault while handling a fault, which
Connection.handlingFaultdetects — skips the message to the peer and disconnects immediately.
Connection.setReadOnly is how a pending disconnect ignores the rest of the
stream: it turns auto-read off and leaves the peer’s remaining packets unread.
Connection.handleDisconnection is the other end of the story. It runs only once the
channel is really closed (from the tick that finds the connection dead, or
straight after a disconnect), reports to the listener — or to Connection.disconnectListener, the client’s connect-attempt
fallback, if the connection never got a real one — and is guarded by
Connection.disconnectionHandled so that it reports at most once.
Keep-alive is the real timeout on a live connection, and it belongs to
the common listener rather than to the play one, so it runs in configuration
too. ServerCommonPacketListenerImpl.keepConnectionAlive sends a challenge
every ServerCommonPacketListenerImpl.LATENCY_CHECK_INTERVAL milliseconds —
fifteen seconds — and disconnects with
ServerCommonPacketListenerImpl.TIMEOUT_DISCONNECTION_MESSAGE if the previous
one was never answered. An answer carrying the wrong id is disconnected with the
same message, so a stale reply disconnects immediately rather than being
ignored.
The round trip a correct answer measures is not used raw: it is smoothed three
parts old to one part new, which is why a tab list lags a genuine latency
change by several pings. Keep-alive stops once the listener has closed itself
behind a terminal packet; from that moment
ServerCommonPacketListenerImpl.checkIfClosed gives the protocol swap another
fifteen seconds and then times the connection out. The thirty-second read
timeout exists only on socket connections, so the singleplayer host has
neither clock running against them: no read timeout on the in-memory pipeline,
and the exemption ServerCommonPacketListenerImpl.isSingleplayerOwner grants
here, from the one kick of the game listener’s three that could reach the
host (players and
sessions).
Questions players ask
Does the server drop packets when it is behind? Not for being late.
Connection keeps no outbound packet queue at all — once the channel exists,
everything written goes into Netty’s own buffer, and nothing in the game asks
whether that buffer is full — and inbound, the PacketProcessor’s queue is unbounded and each
drain empties it. What a slow server costs you is latency, not messages, until
the keep-alive gives up.
Why does kicking someone sometimes stall the caller? Never when /kick
does it: the kick and ban commands defer Connection.disconnect — the call
that blocks on the channel close — to an event-loop callback, and the
BlockableEventLoop.executeBlocking after it runs
Connection.handleDisconnection in place on the Server thread, which returns
at once while the channel is open. What stalls is elsewhere: a login refusal
from the login’s tick calls Connection.disconnect itself, and the Server
thread waits for the socket to shut; a kick made on the Netty thread, such as a
wrong keep-alive id, waits in BlockableEventLoop.executeBlocking for the
Server thread.
Is a flood of packets rate-limited? Only if the server was configured for
it. RateKickingConnection overrides Connection.tickSecond to kick a client
whose average received-packet rate exceeds
RateKickingConnection.rateLimitPacketsPerSecond, and the socket accept site
builds one only when the server’s rate limit is above zero — which it is not by
default; the in-memory one never does. An ordinary socket gets a plain Connection.
Why is the network graph in the debug screen client-side only?
Connection.bandwidthDebugMonitor is inbound-only and socket-only, set from
ConnectScreen and the Realms connect path and nowhere else, so no server ever
has one. MonitoredLocalFrameDecoder exists for the singleplayer case and is
never installed: the local pipeline is always built with a null monitor.
That is the transport: one wire, two ends, a thread hop at each of them, and a picture that does not change between singleplayer and a public server. What crosses — what a packet class has to declare, how its fields become bytes, how the far side knows which class to build, and what stops a hostile sender from allocating a gigabyte — is the other half of this lecture, packets and stream codecs.
Where to look
Connection is the page, and it is worth reading in three sittings rather
than one. First Connection.channelRead0 and Connection.exceptionCaught —
the two ends of a packet’s life at this class, arrival and fault. Then
Connection.configureSerialization and Connection.setupInboundProtocol
beside each other, which is the pipeline built once and then rebuilt by
writing through itself. Then Connection.tick, six duties in one method and
the one whose job is the tick.
Around it, in the order this page meets them:
PacketUtils.ensureRunningOnSameThread for the hop, one test and the
reason every handler is written the way it is; PacketProcessor for
what the hop enqueues and who drains it; PacketDecoder and
PacketEncoder for the two places a codec runs; and
UnconfiguredPipelineHandler with ProtocolSwapHandler, the
placeholders and the helpers the codecs call to become them, which between
them are the phase change. ServerConnectionListener is the
accept side and the one catch that treats a memory connection differently.
Four doors this page only points at: Varint21FrameDecoder, which defines
what a frame is; PacketSendListener, for everything the game
does after a packet is really on the wire; RateKickingConnection, the
subclass almost nobody runs; and EventLoopGroupHolder, where the choice
of transport is made.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Packets and stream codecs
Verified against Minecraft 26.3 · Part IX · What the thing crossing the wire is: a value with no bytes, a number nobody chose, and the limits that stop a hostile sender.
The connection hands you a frame: a length, then a run
of bytes that a handler on a Netty thread is about to turn into a method
call on the other side. This page is what is inside that frame. A chat line
leaving the server is a ClientboundSystemChatPacket — a record of a
Component and a boolean — and it has no write method, no byte layout of
its own, and no number. The number that goes on the wire in front of it is
written down nowhere in the game: not on the packet, not on its
PacketType, not in any table a human maintains. A packet’s id is the
position of one line in a chain of registration calls. Insert or remove one of
those lines and every packet after it renumbers — and because two dozen packet
types are registered into several phases, the same packet type is a
different number in each phase it appears in.
The cast
| class | what it decides | thread |
|---|---|---|
Packet | that a message is a value with a type, one handler method and two flags — and nothing at all about bytes | any |
PacketType | which message this is: a direction and a name, and no number | — |
StreamCodec | one value’s bytes — an encoder and a decoder over a ByteBuf, composed out of its fields’ codecs | Netty |
ByteBufCodecs | the primitive vocabulary the packet codecs are built from, and where the read limits sit | Netty |
IdDispatchCodec | one codec for a whole phase: a var-int id, then delegate to the entry that id names | Netty |
ProtocolInfo | what a configured connection holds — the phase, the direction, that one codec, and the bundler | Netty |
ProtocolInfoBuilder | the registration chain, and therefore every packet number in the game | class-load, or the configuration-to-play swap |
RegistryFriendlyByteBuf | that a registry id on the wire means something, by carrying the RegistryAccess it is relative to | Netty |
The catalogue of which packets exist is generated, not written:
reference/packets.md lists all 235 packet
types across the eight *PacketTypes classes that declare them.
A packet is a value, a name and a direction
Packet is an interface with four methods and one static helper.
| member | what it says |
|---|---|
Packet.type | the PacketType, always a constant from one of the eight *PacketTypes classes |
Packet.handle | hands this packet to one named method on the phase’s listener interface — see below |
Packet.isSkippable | default false. True for five packets, so a failure to encode one is dropped rather than fatal |
Packet.isTerminal | default false. True for the seven packets that end a protocol phase |
Packet.codec | a static convenience: StreamCodec.ofMember under a friendlier name |
Exactly one family of packets refuses to be handled at all:
BundleDelimiterPacket makes Packet.handle final and throws, because a
delimiter is consumed by the pipeline and must never reach a listener. It is
abstract and has one subclass, ClientboundBundleDelimiterPacket, which is
the one that is actually registered — see a bundle is two empty markers,
below. The skippable set is five packets, and what they share is not chat: each
carries a payload composed at run time from data rather than assembled from
fixed fields — a Component in four of them
(ClientboundSystemChatPacket, ClientboundPlayerChatPacket,
ClientboundDisguisedChatPacket and ClientboundPlayerCombatKillPacket, the
death message) and an arbitrary CompoundTag in the fifth,
ClientboundTagQueryPacket, which answers the debug key that copies a
targeted block or entity with its data. Those are the payloads
likeliest to fail to encode, and the five whose failure to encode a connection survives. The
terminal set is exactly the seven transition packets drawn on
protocol phases and no others; what the
flag does to the pipeline is the business of the
connection. Beware
the namesake: ServerboundResourcePackPacket.Action.isTerminal asks
whether a resource-pack response is a final answer, and that packet is
not terminal.
The interface Packet.handle targets is per phase and per direction, and
those interfaces are the quietest large family in the part: ClientGamePacketListener
and ServerGamePacketListener are the two big ones — one method per packet,
which is why the play pair declares nearly two hundred methods — with
ClientCommonPacketListener and ServerCommonPacketListener above them and a
pair each for status, login, configuration, cookie and ping. Handshake has only
a serverbound one, and every phase’s interface is rooted in
ClientboundPacketListener or ServerboundPacketListener. Beyond
ServerPacketListener’s error policy, each phase’s interface naming its
protocol and the two roots their direction, nothing in them decides anything: they are the vocabulary a phase’s *Impl class
promises to implement, and a packet arriving at a listener that implements the
wrong one is the failed class cast and invalid_packet kick
the connection describes
(protocol phases tabulates which *Impl
serves which phase).
PacketType is a record of two things — a PacketFlow and an
Identifier. A direction and a name; no number, no version, no size.
PacketFlow is the two-constant enum PacketFlow.SERVERBOUND /
PacketFlow.CLIENTBOUND, with PacketFlow.getOpposite and
PacketFlow.id.
Three shapes of packet class coexist in the tree, and two of them carry the
argument. The modern one is a record
whose STREAM_CODEC is a StreamCodec.composite naming each component’s
codec and accessor — ClientboundSystemChatPacket is one. The older one is
a plain class with a private buffer constructor and a private write method,
joined into a codec by Packet.codec; ClientboundKeepAlivePacket and
ClientboundSetHealthPacket are these,
and StreamMemberEncoder exists chiefly so that second form can bind a
member reference as its encoder half — CustomPacketPayload, DisplayInfo and
TrackedWaypoint bind one too. The third shape is the one with no fields to serialise at all:
fifteen packet classes that declare a StreamCodec.unit codec, every one but
ServerboundPlayerLoadedPacket a singleton (the bundle delimiter is handed
one by ProtocolInfoBuilder.withBundlePacket), of which
ServerboundFinishConfigurationPacket is the one this part’s login trace
turns on.
From two fields to a numbered blob
flowchart TD
subgraph V["knows chat, not ids"]
direction TB
F1["content, a Component"]
F2["overlay, a boolean"]
SC1["ComponentSerialization.<br/>TRUSTED_STREAM_CODEC"]
SC2["ByteBufCodecs.BOOL"]
COMP["ClientboundSystemChatPacket.STREAM_CODEC, a StreamCodec.composite of both"]
F1 --> SC1
F2 --> SC2
SC1 --> COMP
SC2 --> COMP
end
COMP --> ENTRY["one ProtocolInfoBuilder.addPacket call, that codec against a PacketType"]
subgraph I["knows ids, not chat"]
direction TB
WRAP["StreamCodec.mapStream, at bind: a fresh RegistryFriendlyByteBuf per call"]
DISP["IdDispatchCodec, one for the clientbound play phase"]
WRAP --> DISP
end
ENTRY --> I
DISP --> OUT["on the wire: a VarInt id, then the fields"]
How one packet class is assembled, cut at the line the section is about:
above it nothing knows an id, below it nothing knows chat. This is the order
things are built in — at send time the IdDispatchCodec runs outermost,
writing the id and then handing the value back up to the composite codec.
Read it downwards and you have the page. Nothing above the
ProtocolInfoBuilder.addPacket line knows anything about ids, and
nothing below it knows anything about chat.
One codec serves an entire phase. ProtocolInfo.codec is a single
StreamCodec — an IdDispatchCodec — and not a table the encoder walks:
encoding looks the PacketType up in a map, and a type never registered
in this protocol is an encoder error naming the unknown packet, which is
how a configuration-phase packet sent during play fails. Decoding reads the
id, bounds-checks it and delegates — and then must have consumed the
frame exactly, or PacketDecoder raises an error naming how many bytes
were left over. The check sits where the frame’s length is still
known. All of
that runs on the Netty event loop, inside PacketEncoder and
PacketDecoder, never on a game thread; the framing, the compression, the
ciphers and the later hop to the game thread belong to
the connection.
The codec layer is small, and composition is all of it
net/minecraft/network/codec is a handful of files and does all of the
composing; the byte-level encodings themselves live outside it, and are
named below. StreamCodec is one interface extending StreamEncoder and
StreamDecoder, so StreamEncoder.encode takes a buffer and a value and
StreamDecoder.decode takes a buffer and returns one. It is deliberately
not a Codec: a packet is written once, read once and must be small, so
it gets hand-laid bytes rather than a document in some format — the
distinction belongs to codecs, NBT and
JSON.
The constructors and combinators are StreamCodec.of,
StreamCodec.ofMember, StreamCodec.unit, StreamCodec.map,
StreamCodec.mapStream, StreamCodec.apply, StreamCodec.dispatch,
StreamCodec.recursive and StreamCodec.cast — plus
StreamCodec.composite in fourteen arities, one through fourteen pairs of
codec and getter followed by a constructor. Fields encode and decode
strictly in argument order, and that ordering is the format
specification: there is no other statement anywhere of what a packet’s
bytes look like. StreamCodec.CodecOperation lets StreamCodec.apply read
left to right, and StreamCodec.dispatch is how registry-dispatched values
travel — ConsumeEffect.STREAM_CODEC, SlotDisplay.STREAM_CODEC and
RecipeDisplay.STREAM_CODEC are built with it.
ByteBufCodecs is the primitive library underneath: SCREAMING_CASE
constants for the fixed things, lowerCamel factories for the parameterised
ones.
| what | the names |
|---|---|
| numbers | ByteBufCodecs.BOOL, ByteBufCodecs.BYTE, ByteBufCodecs.SHORT, ByteBufCodecs.UNSIGNED_SHORT, ByteBufCodecs.INT, ByteBufCodecs.VAR_INT, ByteBufCodecs.LONG, ByteBufCodecs.VAR_LONG, ByteBufCodecs.FLOAT, ByteBufCodecs.DOUBLE |
| bytes and text | ByteBufCodecs.BYTE_ARRAY, ByteBufCodecs.LONG_ARRAY, ByteBufCodecs.STRING_UTF8, ByteBufCodecs.byteArray, ByteBufCodecs.stringUtf8, ByteBufCodecs.PLAYER_NAME |
| tags and JSON | ByteBufCodecs.TAG, ByteBufCodecs.COMPOUND_TAG, ByteBufCodecs.TRUSTED_TAG, ByteBufCodecs.TRUSTED_COMPOUND_TAG, ByteBufCodecs.lenientJson |
| shapes and identities | ByteBufCodecs.VECTOR3F, ByteBufCodecs.QUATERNIONF, ByteBufCodecs.RGB_COLOR, ByteBufCodecs.CONTAINER_ID, ByteBufCodecs.GAME_PROFILE, ByteBufCodecs.GAME_PROFILE_PROPERTIES |
| structure | ByteBufCodecs.optional, ByteBufCodecs.collection, ByteBufCodecs.list, ByteBufCodecs.map, ByteBufCodecs.either, ByteBufCodecs.lengthPrefixed |
| registries | ByteBufCodecs.idMapper, ByteBufCodecs.registry, ByteBufCodecs.holder, ByteBufCodecs.holderRegistry, ByteBufCodecs.holderSet |
Two are worth naming for what they encode rather than for what they are.
ByteBufCodecs.ROTATION_BYTE is one byte meaning 1/256 of a full turn — a
little over a degree, with Mth.packDegrees and Mth.unpackDegrees doing
the arithmetic — and ByteBufCodecs.OPTIONAL_VAR_INT spends zero for
absent and value-plus-one otherwise.
The bridge to the disk-and-JSON codecs of Part II is
ByteBufCodecs.fromCodec and its relatives, which run an ordinary Codec
into a carrier format and put the result on the wire. The combinator
underneath takes the ops as an argument and is format-agnostic, and the six
NBT entry points either reach it with NbtOps or, the three that read
registries, do its work themselves over a registry view of NbtOps, so a Codec on the wire
almost always means an NBT tag. Beneath it, ByteBufCodecs.tagCodec takes an
NbtAccounter supplier — which is exactly what trusted turns out to mean,
below.
Almost always, because the combinator is public and exactly two packets call
it with JsonOps and carry JSON strings instead: the server-list response,
ClientboundStatusResponsePacket, and the login kick,
ClientboundLoginDisconnectPacket. Both are read by a client that may speak
another version — the server list’s ping, and a client the server has just
refused — and both write against no registries at all. They are also the two a player is
likeliest to have seen.
IdDispatchCodec is the class that makes a protocol out of a pile of
codecs: a list of serialisers, a type-to-int map, a var-int written in
front and a delegation behind it. IdDispatchCodec.DontDecorateException
is the marker meaning rethrow me as I am, do not wrap me, and
IdDispatchCodec.Builder.build refuses a duplicate PacketType outright,
so registering one type twice in a protocol fails loudly rather than
silently shadowing an id. Loudly, but not always early: the check runs when
the template is bound, which for the four phases whose codecs are fixed —
handshaking, status, login and configuration — is class-load, and for play is
the first connection’s configuration-to-play switch (which buffer, and why
play needs its own, below).
Which buffer, and why play needs its own
| phase and direction | what the codec is handed | bound |
|---|---|---|
| status, serverbound | the raw ByteBuf — the identity function, no wrapper at all | at class-load |
| handshaking, status clientbound, login, configuration | FriendlyByteBuf | at class-load |
| play, both directions | RegistryFriendlyByteBuf — a FriendlyByteBuf with one more field | per connection, at the switch into play |
Three rows and no figure, because there is no order here to draw: the third row is not the second one wrapped again, it is a subclass of it, and every one of the three is built round the raw buffer.
FriendlyByteBuf is a ByteBuf decorator declaring a hundred and forty-six
readers and writers, counting every overload and static twin separately, eighty-eight of which add a wire format the
plain buffer knows nothing about — FriendlyByteBuf.readVarInt,
FriendlyByteBuf.writeUtf, FriendlyByteBuf.readIdentifier,
FriendlyByteBuf.writeResourceKey, FriendlyByteBuf.readNbt,
FriendlyByteBuf.readEnumSet, FriendlyByteBuf.readBlockPos,
FriendlyByteBuf.readWithCodec and so on. It holds the two length
constants FriendlyByteBuf.MAX_STRING_LENGTH and
FriendlyByteBuf.MAX_COMPONENT_STRING_LENGTH.
RegistryFriendlyByteBuf extends it and adds exactly one field, a
RegistryAccess, behind RegistryFriendlyByteBuf.registryAccess. It
exists because a numeric registry id means nothing on its own — enchantment
number 3 is only an enchantment relative to the registry set the server sent
during configuration (identifiers and
registries). Every codec
that reads the RegistryAccess needs one: ByteBufCodecs.registry,
ByteBufCodecs.holderRegistry, ByteBufCodecs.holder,
ByteBufCodecs.holderSet, ByteBufCodecs.fromCodecWithRegistries,
ByteBufCodecs.fromCodecWithRegistriesTrusted and
ByteBufCodecs.registryFriendlyLengthPrefixed (ByteBufCodecs.idMapper, over
a fixed map such as the block states, needs none) — and therefore
ItemStack.STREAM_CODEC, DataComponentPatch.STREAM_CODEC
(data components),
ComponentSerialization.STREAM_CODEC and HashedStack.STREAM_CODEC. The
wrapper is a throwaway, not a pipeline object: ProtocolInfoBuilder
applies the decorator — RegistryFriendlyByteBuf.decorator for play —
through StreamCodec.mapStream, so a fresh view is constructed round the
raw buffer on every single encode and every single decode.
When each of those bindings happens is the other half of the same fact.
Handshaking, status, login and configuration are the four static phases:
they bind their buffers eagerly at class-load, and the serverbound status
protocol binds the identity function, so it is the one phase and direction
that never wraps its bytes at all. Play — the fifth — binds per
connection, at the configuration-to-play transition. On the client that
really is the first moment a RegistryAccess exists; on the server the
registries have been loaded since startup, and the rebind happens because the
protocol changed rather than because the registries arrived.
Below all of it the actual encodings live in VarInt, VarLong,
Utf8String and LpVec3, the low-precision vector behind
Vec3.LP_STREAM_CODEC, mostly velocities. Two everyday values are worth naming because they
are not special-cased: Identifier.STREAM_CODEC is a plain UTF-8 string
under the ordinary 32,767-character cap — an identifier on the wire is
text, never an interned number — and UUIDUtil.STREAM_CODEC is two longs.
Where a packet’s number comes from
ProtocolInfo is what a configured connection holds:
ProtocolInfo.id (a ConnectionProtocol), ProtocolInfo.flow,
ProtocolInfo.codec — the single phase-wide StreamCodec — and a nullable
ProtocolInfo.bundlerInfo. It is built by ProtocolInfoBuilder, whose
ProtocolInfoBuilder.addPacket and ProtocolInfoBuilder.withBundlePacket
are the registration calls and whose ProtocolInfoBuilder.buildUnbound
yields an UnboundProtocol or a SimpleUnboundProtocol — a protocol that
knows everything except which buffer type to wrap the bytes in (and, for the
one context protocol, its context).
UnboundProtocol.bind supplies that. Underneath it, ProtocolCodecBuilder
is the layer that talks to IdDispatchCodec.
The id is a registration index. ProtocolCodecBuilder.add appends to a
list and IdDispatchCodec.Builder.build walks that list assigning 0, 1, 2
in call order, so a packet’s wire number is its position in the chain of
ProtocolInfoBuilder.addPacket calls — ProtocolInfoBuilder.withBundlePacket’s
delimiter counting, in the one protocol that has one — in GameProtocols,
ConfigurationProtocols, LoginProtocols, StatusProtocols or
HandshakeProtocols. ProtocolCodecBuilder.add also asserts that the
type’s PacketFlow matches the protocol’s, so a clientbound type cannot be
registered into a serverbound protocol.
Those numbers are readable from outside for one reason.
ProtocolInfo.DetailsProvider and ProtocolInfo.Details let tooling
enumerate a phase, and ProtocolInfo.Details.listPackets hands a
ProtocolInfo.Details.PacketVisitor each PacketType with its network id.
The tooling is the data generator: PacketReport walks every template and
writes every packet’s id in every phase into a report — the only place in
the project those numbers are written down.
There are four registration entry points, one per direction and per
context-or-not: ProtocolInfoBuilder.serverboundProtocol,
ProtocolInfoBuilder.clientboundProtocol,
ProtocolInfoBuilder.contextServerboundProtocol and
ProtocolInfoBuilder.contextClientboundProtocol. The context ones let a
codec ask the connection a question, and exactly one protocol uses one:
GameProtocols.SERVERBOUND_TEMPLATE, whose context is GameProtocols.Context
and whose two questions are GameProtocols.Context.hasInfiniteMaterials and
GameProtocols.Context.canUseCommandBlocks, the second deciding how long a
command the server will read from a ServerboundCommandSuggestionPacket. The
other eight templates —
nine in all, one per direction per phase bar handshaking’s serverbound-only
one — are a SimpleUnboundProtocol; CodecModifier is the hook a
context-aware codec is installed through.
A bundle is two empty markers round ordinary packets
ClientboundBundlePacket has a PacketType and no wire id at all.
ProtocolInfoBuilder.withBundlePacket registers only the delimiter,
ClientboundBundleDelimiterPacket, serialised with StreamCodec.unit, and
records a BundlerInfo beside the codec list, so a bundle on the wire is two empty markers with ordinary,
individually numbered packets between them. PacketBundleUnpacker explodes
an outgoing bundle into delimiter-packets-delimiter and
PacketBundlePacker collects an incoming run back up. BundlePacket is the
abstract class that holds the sub-packets and ClientboundBundlePacket its one
subclass; BundlerInfo is the logic, split between
BundlerInfo.unbundlePacket outgoing and its nested BundlerInfo.Bundler
incoming, and BundlerInfo.BUNDLE_SIZE_LIMIT caps a bundle at 4,096
sub-packets. Only the clientbound play protocol declares a bundle at all.
What a bundle buys is atomicity against the client’s tick.
ClientPacketListener.handleBundlePacket hops to the Render thread once for
the whole bundle and then handles the sub-packets inline, so the client can
never tick or render with half a bundle applied. There are only two
senders, both in ServerEntity: ServerEntity.addPairing, which collects
what ServerEntity.sendPairingData writes into a list and sends the result
as one bundle, so an entity never appears mid-initialisation (what the
client is told); and the motion-plus-power
pair sent for a hurtling projectile.
What stops a hostile sender
The frame limit does most of the work and it is not the codec layer’s: a
frame length is at most three var-int bytes, and Varint21FrameDecoder
refuses a wider prefix or a zero length before any codec sees anything (the
connection owns it, and it
is in the table below because a reader chasing a limit should not have to know
which layer imposed it). Above that sit two separate
collection defences, and only the second is famous.
ByteBufCodecs.readCount is the first — it compares the declared count
against the codec’s own maximum and refuses outright, which is what makes
the three-argument ByteBufCodecs.collection different from the
two-argument one whose maximum is effectively unbounded. Behind it,
ByteBufCodecs.MAX_INITIAL_COLLECTION_SIZE clamps the allocation to
65,536 entries whatever the count says, so even an accepted count cannot
force a huge array up front. ByteBufCodecs.lengthPrefixed bounds the
bytes instead of the count, handing the inner codec a slice it physically
cannot read past.
Trusted is a real distinction in the codec library, and the mechanism is a
read budget: ByteBufCodecs.fromCodecTrusted gives the NBT reader an
unlimited heap quota where plain ByteBufCodecs.fromCodec gives it the
default two mebibytes at depth 512. The rule for choosing between them is
direction — the server wrote it, so the client may trust it — which is why
the trusted half of each pair is clientbound only. Hence
ByteBufCodecs.COMPOUND_TAG / ByteBufCodecs.TRUSTED_COMPOUND_TAG — the
plain one carries CustomData and the predicates, and the trusted one has
exactly one call site in the game, ClientboundBlockEntityDataPacket;
ByteBufCodecs.TAG / ByteBufCodecs.TRUSTED_TAG, whose trusted form has none
at all and is a pair only on paper; and
ComponentSerialization.STREAM_CODEC /
ComponentSerialization.TRUSTED_STREAM_CODEC, the live one — every
clientbound chat packet uses the trusted variant, which is the codec this
page’s own opening figure draws on a ClientboundSystemChatPacket’s
Component field (text
components).
Exactly one packet lets a client hand the server an arbitrary item, and
it is fenced three ways. ServerboundSetCreativeModeSlotPacket uses
ItemStack.OPTIONAL_UNTRUSTED_STREAM_CODEC, which differs from
ItemStack.OPTIONAL_STREAM_CODEC only in using
DataComponentPatch.DELIMITED_STREAM_CODEC:
every component’s payload is length-prefixed, and
ByteBufCodecs.lengthPrefixed hands the inner codec a slice and advances
the outer reader past the whole region before delegating, so a component
that lies about its own length cannot mis-frame the ones after it. That is
containment, not recovery — nothing catches a component that throws, and
one bad component still fails the whole packet. It is then wrapped in
ItemStack.validatedStreamCodec, which re-encodes the decoded stack through
ItemStack.CODEC against NullOps purely to collect its errors — the output
is thrown away, and the point is to prove that the persistent codec would
have accepted it. And the registration
carries GameProtocols.HAS_INFINITE_MATERIALS, a CodecModifier that
refuses the packet whenever its context says the connection is not in
creative — a SkipPacketDecoderException one way and a
SkipPacketEncoderException the other, landing on one side only because
the client’s context answers GameProtocols.Context.hasInfiniteMaterials
true unconditionally while the server’s context is
ServerGamePacketListenerImpl, answering from the real player. The
packet is refused in the decoder, before any handler exists to fool.
Serverbound defence is layered, and the three layers sit on three different
packets. Full re-validation is the creative slot above.
ServerboundCustomClickActionPacket takes the second: it builds its own,
much tighter NbtAccounter and length-prefixes its payload, so a custom
click’s tag is bounded rather than checked. And the third is the strongest,
because there is nothing to check.
The ordinary container click is defended by carrying nothing to validate.
ServerboundContainerClickPacket sends a HashedStack, which is an item, a count
and a hash per component its patch adds (the removed ones by type alone), so on that packet no client-supplied
component content crosses the wire at all. What the shape is and what the server does with the claim belong to containers
and menus.
The limits in one table:
| limit | value | where |
|---|---|---|
| frame length prefix | three var-int bytes | Varint21FrameDecoder |
| compressed frame | just under 2 MiB, all a three-byte prefix can count | Varint21FrameDecoder |
| decompressed frame | 8 MiB, for every sender, and on receipt for the server only | CompressionDecoder.MAXIMUM_UNCOMPRESSED_LENGTH |
| default string | 32,767 chars | FriendlyByteBuf.MAX_STRING_LENGTH |
| component as string | 262,144 | FriendlyByteBuf.MAX_COMPONENT_STRING_LENGTH |
| player name | 16 | ByteBufCodecs.PLAYER_NAME |
| collection allocation | 65,536 | ByteBufCodecs.MAX_INITIAL_COLLECTION_SIZE |
| sub-packets in a bundle | 4,096 | BundlerInfo.BUNDLE_SIZE_LIMIT |
| slots in one click | 128 | ServerboundContainerClickPacket.MAX_SLOT_COUNT |
| var-int / var-long | 5 / 10 bytes | VarInt.read, VarLong.read |
Custom payloads, a seam in a fixed packet set
The packet set is code, fixed at compile time, and no data pack adds to it.
The seam after login is CustomPacketPayload, with CustomPacketPayload.Type,
CustomPacketPayload.createType and CustomPacketPayload.codec, carried
by ClientboundCustomPayloadPacket and ServerboundCustomPayloadPacket.
The route in for an id nobody registered is
CustomPacketPayload.FallbackProvider, the factory the dispatch asks for one, with CustomPacketPayload.TypeAndCodec as
the registration pair; BrandPayload is vanilla’s own use of the
mechanism.
An unrecognised payload decodes to DiscardedPayload — a record of the
identifier and nothing else. Its decoder checks the remaining length
against a per-direction maximum and then skips every byte, and its encoder
writes nothing. The payload is discarded, not held.
The login phase has its own parallel set — CustomQueryPayload,
CustomQueryAnswerPayload and the discarding implementations of each — because
the play-phase seam does not exist yet when a login is negotiated. Vanilla
decodes every one of them to the discarding form and never sends a query,
which is why any answer is a disconnect (protocol
phases).
What a skippable packet actually costs
A chat line that fails to encode does not end your connection, and the reason
is worth following because it is the codec layer deciding that an error is not
a fault — as it also does in the creative-slot fence above and in the
command-suggestion packet’s chat-only cap. SkipPacketException is a bare
marker; SkipPacketEncoderException and SkipPacketDecoderException
implement it and IdDispatchCodec.DontDecorateException. PacketEncoder
turns a failure on a Packet.isSkippable packet into one. PacketDecoder
drains the rest of the frame and rethrows. What keeps the connection alive is
Connection.exceptionCaught, which logs the marker and returns (the
connection).
The old shape is weaker only where it does its own counting. FriendlyByteBuf
has no list or map reader, so a hand-written packet’s counted list goes
through a ByteBufCodecs codec — ClientboundSetPlayerTeamPacket builds its
player list with ByteBufCodecs.list — and gets the clamped allocation with
it. A count the packet decodes itself gets no such cap:
ClientboundSectionBlocksUpdatePacket sizes both of its arrays from the raw
decoded count with nothing of its own bounding it.
One type, several encodings
Type and encoding are separate objects, and three packets prove it by having
two encodings each — ServerboundCommandSuggestionPacket’s are the protocol
context’s choice, above. ClientboundCustomPayloadPacket declares
ClientboundCustomPayloadPacket.GAMEPLAY_STREAM_CODEC and
ClientboundCustomPayloadPacket.CONFIG_STREAM_CODEC;
ClientboundShowDialogPacket declares
ClientboundShowDialogPacket.STREAM_CODEC and
ClientboundShowDialogPacket.CONTEXT_FREE_STREAM_CODEC. For the other two, the same
PacketType is registered with the first in GameProtocols and with the
second in ConfigurationProtocols — the same message, typed against the
buffer each phase has, and for the dialog written differently, since only play
has a RegistryAccess to write a registry reference against.
Which packets a phase registers, and in what order, is therefore the whole definition of that phase, so the next page is protocol phases, where nine codec tables — one per direction per phase, bar handshaking’s serverbound-only one — become the four languages a joining connection speaks in turn.
For a 1.21-era reader.
Packetno longer knows how to write itself. There is no write method on the interface and no buffer constructor it is obliged to have. Serialisation is a STREAM_CODEC static field that the protocol description reads — which is why one packet class can have two of them, and why a packet class need not own one at all. FriendlyByteBuf.readList, readMap, readCollection and limitValue are gone:ByteBufCodecs.list,ByteBufCodecs.mapandByteBufCodecs.collection, which can take the cap as an argument, do their work.
Where to look
Three files in this order and the page’s argument is in front of you.
Packet is an interface of four methods and takes a minute.
GameProtocols is the chain — scroll it, and the claim that an id is a
line’s position stops being a claim. ProtocolInfoBuilder is what the
chain is calling, and ProtocolInfoBuilder.addPacket is the line that assigns
the number without ever naming one.
Then the two layers underneath. StreamCodec for the combinators, and
StreamCodec.composite in particular, because the fourteen arities are the
whole reason a packet needs no write method. IdDispatchCodec for how a
phase’s codecs become one codec, and for the duplicate check that fires at
bind time rather than at registration.
ByteBufCodecs is a library rather than a read: open it when you want to
know how one kind of value is written, and read ByteBufCodecs.readCount and
ByteBufCodecs.lengthPrefixed whatever else you skip, since between them they
are most of the defence. FriendlyByteBuf is the same for the old shape.
Two doors this page only points at: PacketReport, a short data
generator and the only place a packet number is ever written down, and
CustomPacketPayload, the seam a modded packet goes through after login.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Protocol phases
Verified against Minecraft 26.3 · Part IX · A login: from clicking a server in the list to standing in the world.
Click a server in the multiplayer list and one TCP connection opens, and over the next few seconds it speaks four different languages in turn: handshaking,
login, configuration, play. There is a fifth, status, and a login never
touches it — it is the phase the server list uses to ask a server how many
players it has, and it is a dead end by design. Each of the five is a
ConnectionProtocol, each has its own packet set, and each that hands over to another does so by a packet marked terminal — a flag on the packet class that makes the
codec which decoded it tear itself out of the pipeline as it passes (the
connection). All
but handshaking have a listener at both ends; handshaking is serverbound only,
so the client has nothing to listen with.
The surprise is not in any of that. It is that the ServerPlayer — the
object, its save data, its position, the chunks under it — is prepared
during configuration, by PrepareSpawnTask, and constructed only after the
client has acknowledged that configuration is over. For the seconds in
between, a server is holding chunk tickets for a player that does not exist,
and by the time the object is built, the server’s side of the connection already encodes play.
The cast
| class | role | thread |
|---|---|---|
ConnectionProtocol | the five phases — a bare enum of labels; the codec lives in a ProtocolInfo, the behaviour in a PacketListener | — |
Connection | one channel, and Connection.setupInboundProtocol / Connection.setupOutboundProtocol at every transition | Netty |
ServerHandshakePacketListenerImpl | the three-way switch and the version gate | Netty |
ServerLoginPacketListenerImpl | the login state machine; its handlers set a volatile state and its ServerLoginPacketListenerImpl.tick acts on it | Netty and the authenticator thread, ticked from Server |
ServerConfigurationPacketListenerImpl | the serial task queue, and the handler that finally builds the player | mixed — see below |
ClientHandshakePacketListenerImpl · ClientConfigurationPacketListenerImpl | the client’s two halves: the session-service call, then the registries and tags it accumulates before constructing ClientPacketListener | Netty, the IO pool for the session call, and Render for most of the second |
ServerCommonPacketListenerImpl · CommonListenerCookie | what configuration and play share — the packets legal in both, and the state that survives a switch between them | Netty and Server |
PrepareSpawnTask | finds a spawn, tickets its chunks, waits — and later, on request, spawns the player | Server, but Netty for its start straight after a code of conduct |
The five phases
stateDiagram-v2
[*] --> HANDSHAKING : TCP accept
HANDSHAKING --> STATUS : ClientIntentionPacket, intent STATUS
HANDSHAKING --> LOGIN : ClientIntentionPacket, intent LOGIN or TRANSFER
STATUS --> [*] : pong, then the server hangs up
LOGIN --> CONFIGURATION : ClientboundLoginFinishedPacket, ServerboundLoginAcknowledgedPacket
CONFIGURATION --> PLAY : ClientboundFinishConfigurationPacket, ServerboundFinishConfigurationPacket
PLAY --> CONFIGURATION : ClientboundStartConfigurationPacket, ServerboundConfigurationAcknowledgedPacket
PLAY --> [*] : disconnect
note right of HANDSHAKING : every packet named here is terminal, so the codec that decoded it is already gone
The five phases, each arrow between two of them carrying the packet — or the pair of them — that changes one. The arrow back out of play is the only one that goes up, which is what makes configuration the only phase entered from two others.
Seven packets in the game carry the terminal flag, and every one of them
labels an arrow above. ClientIntentionPacket labels two, which is the
striking one: the very first packet a connection ever sends already tears out
the codec that decoded it. The remaining six are three pairs, and every pair
enters or leaves configuration, the only phase entered from two others.
ConnectionProtocol is five constants — ConnectionProtocol.HANDSHAKING,
ConnectionProtocol.STATUS, ConnectionProtocol.LOGIN,
ConnectionProtocol.CONFIGURATION, ConnectionProtocol.PLAY — each
carrying only a string ConnectionProtocol.id. There is no number, no
packet table and no lookup by id. What a phase is lives in two other
places: the packet set, bound as a ProtocolInfo, and the listener that
handles it.
| phase | serverbound listener | clientbound listener | ProtocolInfo |
|---|---|---|---|
ConnectionProtocol.HANDSHAKING | ServerHandshakePacketListenerImpl, or MemoryServerHandshakePacketListenerImpl in singleplayer | none — there is no clientbound handshake protocol | HandshakeProtocols.SERVERBOUND, one packet |
ConnectionProtocol.STATUS | ServerStatusPacketListenerImpl | an anonymous ClientStatusPacketListener inside ServerStatusPinger | StatusProtocols.SERVERBOUND binds a raw buffer; StatusProtocols.CLIENTBOUND a FriendlyByteBuf |
ConnectionProtocol.LOGIN | ServerLoginPacketListenerImpl | ClientHandshakePacketListenerImpl | LoginProtocols.SERVERBOUND / LoginProtocols.CLIENTBOUND |
ConnectionProtocol.CONFIGURATION | ServerConfigurationPacketListenerImpl | ClientConfigurationPacketListenerImpl | ConfigurationProtocols.SERVERBOUND / ConfigurationProtocols.CLIENTBOUND |
ConnectionProtocol.PLAY | ServerGamePacketListenerImpl | ClientPacketListener | not pre-bound — GameProtocols.SERVERBOUND_TEMPLATE and GameProtocols.CLIENTBOUND_TEMPLATE are bound per connection |
The bindings in the last column are the per-phase codec tables that packets and stream codecs builds, and the swap itself is the pipeline surgery the connection performs; this page is what the swaps are for. The one thing about the column that is a fact about the phases is that play is the only row not pre-bound: its codecs write registry ids, so its two templates are bound per connection at the configuration-to-play switch, which is the only transition in a connection’s life that ties the codecs to a set of registries as well as changing which packets are legal.
Two listeners sit under the phases. ServerCommonPacketListenerImpl is the
shared base of the server’s configuration and play listeners and holds
nearly everything legal in both (each subclass handles the client information
itself) — keep-alive, latency, custom payloads,
resource-pack responses and the flush suspension — with
ClientCommonPacketListenerImpl its client counterpart; that inheritance
is why the common packets in reference/packets.md
belong to no single phase. And the state that crosses a phase change is a
CommonListenerCookie: on the server a small record of profile, latency,
client information and a transferred flag; on the client a much larger one
carrying registries, feature flags, cookies, chat state and the server
brand.
Handshake
The handshake is one packet and a three-way switch. ClientIntentionPacket
carries the protocol version, the address the client dialled and a
ClientIntent, and ServerHandshakePacketListenerImpl.handleIntention
branches on it. ClientIntent.STATUS installs the status listener, or
disconnects at once if the server does not reply to status.
ClientIntent.TRANSFER disconnects if the server does not accept transfers,
and otherwise joins ClientIntent.LOGIN in
ServerHandshakePacketListenerImpl.beginLogin, which compares the client’s
protocol version against this build’s and refuses a mismatch, and the two
refusals are not symmetric: a version below protocol 754 is told outdated_client, and anything else that does not match is
told incompatible. All three of the handshake’s refusals on the login path — these two and the transfer refusal —
go out as ClientboundLoginDisconnectPacket under the login clientbound
protocol, so the client can render a reason:
ServerHandshakePacketListenerImpl.beginLogin installs it for every login
before its check, and the transfer refusal installs it purely to send one. The status refusal is the exception and the
rudest: the status clientbound protocol is installed before the branch, and
then the connection is dropped with no packet at all.
The client does not wait for any of that. Whichever of its three entry
points opened the connection — ConnectScreen for a listed or direct
server, Minecraft for the integrated server’s memory channel,
RealmsConnect — it sends ServerboundHelloPacket immediately after the
intention packet; there is no round trip between them. The profile id the hello carries is decoded and then never read — the
server mints or fetches identity for itself. All of this runs on the Netty
thread, and the intention packet, being terminal, has already torn out the
codec that decoded it.
Status, the phase nobody logs in through
ConnectionProtocol.STATUS is two packets each way and a deliberate dead
end. ServerStatusPacketListenerImpl answers exactly one
ServerboundStatusRequestPacket — a second one disconnects the caller —
and answers ServerboundPingRequestPacket with
ClientboundPongResponsePacket and then hangs up. A status connection
is expected to be thrown away, which is why ServerStatusPinger opens one
per server in the list and why Connection.initiateServerboundStatusConnection
exists as a separate entry point.
Login
The login phase is a state machine on one field, and the states are the ones the server moves through between accepting a socket and having a profile it believes.
stateDiagram-v2
[*] --> HELLO
HELLO --> KEY : online mode, ClientboundHelloPacket
HELLO --> VERIFYING : the singleplayer profile
HELLO --> VERIFYING : offline mode
KEY --> AUTHENTICATING : ServerboundKeyPacket
AUTHENTICATING --> VERIFYING : the authenticator thread returns
VERIFYING --> WAITING_FOR_DUPE_DISCONNECT : tick, a duplicate is online
VERIFYING --> PROTOCOL_SWITCHING : tick, ClientboundLoginFinishedPacket
WAITING_FOR_DUPE_DISCONNECT --> PROTOCOL_SWITCHING : tick, the old one is gone
PROTOCOL_SWITCHING --> ACCEPTED : ServerboundLoginAcknowledgedPacket
note right of VERIFYING : the three tick edges are the server thread's, one the authenticator thread's, the rest Netty's
Seven of the eight ServerLoginPacketListenerImpl.State constants, and the
three edges the server thread owns. Look at where the forks are: the machine
branches once out of the hello, on how — or whether — this login is
authenticated, and then again on whether the profile is already playing.
No handler on ServerLoginPacketListenerImpl ever schedules itself onto
another thread (there is not one PacketUtils.ensureRunningOnSameThread call
in the class), and yet the login is finished on the server thread, which is
why its ServerLoginPacketListenerImpl.state field is volatile: the packet
handlers run on the Netty thread and set the state, as the authenticator thread
below does, and
ServerLoginPacketListenerImpl.tick — reached from
MinecraftServer.tickConnection through ServerConnectionListener.tick
and Connection.tick, the tick a game thread gives a connection (the
connection)
— reads it on the server thread and does the login.
The tick is also where ServerLoginPacketListenerImpl.MAX_TICKS_BEFORE_LOGIN,
six hundred ticks, is enforced: a client that has not reached the end of
the phase in thirty seconds is disconnected for a slow login — a transition
out of every state above but the last, and the one the diagram cannot draw.
The diagram names three states the paragraphs below do not: ServerLoginPacketListenerImpl.State.AUTHENTICATING
is the wait on the session service,
ServerLoginPacketListenerImpl.State.PROTOCOL_SWITCHING is the gap between
the server sending ClientboundLoginFinishedPacket and the client
acknowledging it, and ServerLoginPacketListenerImpl.State.ACCEPTED is where
the machine stops, because configuration has its own listener.
Three branches out of the hello. If the name matches the singleplayer
profile, verification starts at once with no encryption. If the server uses
authentication and this is not a memory connection, the state becomes
ServerLoginPacketListenerImpl.State.KEY and ClientboundHelloPacket
carries the server’s RSA public key and a four-byte challenge. Otherwise —
offline mode — the profile is minted from the name by
UUIDUtil.createOfflineProfile and nothing is encrypted.
Both sides authenticate, and the client goes first.
sequenceDiagram
box transparent the client
participant CHPL as ClientHandshake<br/>PacketListenerImpl
end
box transparent the server
participant SLPL as ServerLoginPacket<br/>ListenerImpl
end
participant Sess as the session service
SLPL->>CHPL: ClientboundHelloPacket, an RSA public key and a challenge
CHPL->>CHPL: a new AES secret, and a digest over id, secret and key
CHPL->>Sess: joinServer, on the client's IO pool, before the key packet
CHPL->>SLPL: ServerboundKeyPacket, RSA-encrypted, ciphers attached to the send
SLPL->>SLPL: the challenge checked, the secret recovered, then<br/>Connection.setEncryptionKey
SLPL->>Sess: hasJoinedServer, on a fresh User Authenticator thread
Sess-->>SLPL: the authenticated profile, state VERIFYING
Note over SLPL: the next server tick runs bans, whitelist, compression, duplicates
Both ends authenticating against the same external service, the client first. Read it downwards: the server has installed both ciphers by the fifth line, two lines before it has any idea who this is.
Read the diagram for the order, because the order is the point: the server
installs both ciphers while handling the key packet, before its own
session-service call has begun. An unauthenticated connection is already
encrypted. The client’s asymmetry is the mirror of it — it calls the session
service before ServerboundKeyPacket goes anywhere and sends it from that
callback, attaching its own ciphers to the send, so the key packet itself
travels in the clear and nothing after it does.
Authentication is a plain thread with two fallbacks. It calls the
session service and, on success, reports login activity, stores the profile
and flips the state to ServerLoginPacketListenerImpl.State.VERIFYING. On
failure it disconnects — unless the server is a singleplayer host, in which
case both a null result and an unreachable authentication service fall back
to an offline profile. That is how a LAN world admits a guest whose account
cannot be checked. A login over a real socket against an authenticating
server starts exactly one such thread; an offline-mode, memory or
singleplayer-profile login starts none.
The tick does the real login.
ServerLoginPacketListenerImpl.verifyLoginAndFinishConnectionSetup runs on
the server thread: PlayerList.canPlayerLogin for bans, whitelist and
capacity; the compression switch; and
PlayerList.disconnectAllPlayersWithProfile for a duplicate login, after
which the machine waits in
ServerLoginPacketListenerImpl.State.WAITING_FOR_DUPE_DISCONNECT until the
old player has left the player list.
Login ends with a terminal packet in each direction, and the two sides
install their codecs in mirror order. ClientboundLoginFinishedPacket then
ServerboundLoginAcknowledgedPacket — but the server installs outbound
configuration when the acknowledgement arrives, in
ServerLoginPacketListenerImpl.handleLoginAcknowledgement, whereas the
client installs inbound configuration before sending it and outbound
immediately after. Both packets are terminal, so the codecs tear themselves
out as they pass (the
connection).
The client then
volunteers two packets straight away: its own BrandPayload and
ServerboundClientInformationPacket, which carries the client’s language,
view distance and skin-part settings — and is therefore the packet a server
with a code of conduct reads before choosing which translation of it to send.
What disconnects a login: a version mismatch, a refused transfer, a ban, a
whitelist the player is not on, a full server, a failed session check on a
non-singleplayer host, any ServerboundCustomQueryAnswerPacket or cookie answer, a malformed or
out-of-order packet, or six hundred ticks. Either answer disconnects because
vanilla never asks: ServerLoginPacketListenerImpl.State.NEGOTIATING is
declared and never assigned, ClientboundCustomQueryPacket decodes every
payload as DiscardedQueryPayload, and nothing sends a cookie request.
Configuration
Configuration is a queue. The order below is fixed, each task finishes before the next begins, and the last two exist only to get a player into a world.
flowchart TD
S["up to three packets, sent before any task runs"]:::server
S --> Q
subgraph Q["the serial queue"]
direction TB
R["SynchronizeRegistriesTask"]:::server --> C["ServerCodeOfConduct<br/>ConfigurationTask"]:::server
C --> P["ServerResourcePack<br/>ConfigurationTask"]:::server
P --> PS["PrepareSpawnTask: a spawn, and a ticket"]:::server
PS --> J["JoinWorldTask: the terminal packet"]:::server
end
Q -- "ClientboundFinishConfigurationPacket" --> CL["the client rebuilds its registries"]:::client
CL -- "ServerboundFinishConfigurationPacket" --> F["the play protocol, the gate again, the player"]:::server
The phase as a queue of five tasks, strictly one at a time — the middle two only if the server has a code of conduct or a resource pack — with the packets that precede the queue and the round trip that ends it. The two arrows at the foot are the phase’s oddity: between them the server is holding a ticket on chunks for a player that does not exist yet.
SynchronizeRegistriesTask is the reason configuration exists, and the
queue around it is strictly serial. ServerConfigurationPacketListenerImpl.startConfiguration
sends up to three things outside the queue — the server’s BrandPayload,
ClientboundServerLinksPacket if there are links, and
ClientboundUpdateEnabledFeaturesPacket — then queues the registry task, a
code-of-conduct task if the server has one (ServerCodeOfConductConfigurationTask,
whose two packets are ClientboundCodeOfConductPacket and the client’s
ServerboundAcceptCodeOfConductPacket) and a resource-pack task if it
has one, before ServerConfigurationPacketListenerImpl.returnToWorld
appends PrepareSpawnTask and JoinWorldTask and starts the first. Each
ConfigurationTask finishes before the next begins:
ServerConfigurationPacketListenerImpl.finishCurrentTask rejects a
completion naming the wrong task type, and an exception out of any task
disconnects the client.
Registry and tag sync is why this phase exists, and what crosses belongs
to identifiers and
registries —
the known-pack negotiation, the all-or-nothing comparison, the empty payloads
for elements the client can re-read from its own jar, and the two layers the
client rebuilds. What belongs to the phase is the order and the count: one
ClientboundSelectKnownPacks and its reply, then one
ClientboundRegistryDataPacket per registry, then a single
ClientboundUpdateTagsPacket that covers the networkable registries and
the static ones too — the surprise, since the data packets are dynamic-only,
and the reason a tag has to land before any play traffic does
(tags). None of it
is applied as it arrives: RegistryDataCollector accumulates the whole
exchange and only resolves it at
ClientConfigurationPacketListenerImpl.handleConfigurationFinished, the
handler that goes on to construct the ClientPacketListener.
The seam is not where it looks. Four of the server’s configuration
handlers hop to the Server thread —
ServerConfigurationPacketListenerImpl.handleSelectKnownPacks,
ServerConfigurationPacketListenerImpl.handleConfigurationFinished and, from
the common base, the resource-pack response and
ServerCommonPacketListenerImpl.handleCustomClickAction —
while the client-information and code-of-conduct handlers, like the
keep-alive and ping handlers they inherit, stay on the Netty thread. That
last one is load-bearing: accepting a code of conduct finishes a task, which
starts the next task on the Netty thread, and the next task may be
PrepareSpawnTask, whose first act is to read a player save file and
resolve a spawn position. ServerConfigurationPacketListenerImpl.tick runs
each tick to drive the current task and keep the spawn chunks loaded.
PrepareSpawnTask is two states, and only the second builds the player, and only when asked.
Its PrepareSpawnTask.Preparing state waits for the spawn position the task’s
start resolved (a search, for a player with none saved), tickets the chunks and then reports not finished from
ConfigurationTask.tick until they arrive; PrepareSpawnTask.Ready reports
finished and does nothing but re-arm the ticket until it is asked to spawn. How it finds the spawn, why the ticket has
to be re-armed every tick, and what the two whole-file reads cost belong to
players and
sessions.
JoinWorldTask then sends the terminal
packet, and only when the client’s ServerboundFinishConfigurationPacket
arrives does ServerConfigurationPacketListenerImpl.handleConfigurationFinished
swap the outbound protocol to play, refuse a duplicate of the player, run the
admission gate a second time
(admission is a Component or
nothing
— a ban or a full server can arrive in the seconds a configuration takes) and
call PrepareSpawnTask.spawnPlayer, which is where the ServerPlayer is
finally constructed and handed to PlayerList.placeNewPlayer.
Everything between the join task and that handler is a
server holding a ticket on chunks for a player that does not exist — which
is the phase’s own oddity, and the reason this page opens on it.
What disconnects a configuration: a task that throws on start or on tick, a
completion for the wrong task, a declined resource pack the server requires,
a cookie answer, a duplicate at the finish or a refusal at the admission gate’s
second run (a ban, the whitelist, a full server), and an exception while
placing the player. Notably not a deadline on the queue. Login has one —
six hundred ticks — and configuration’s queue has none:
ServerConfigurationPacketListenerImpl.tick counts nothing, calling the
common base’s keep-alive and then the current task, so what kills a stuck
task is a client that stops answering keep-alives rather than one that takes
too long. Only the round trip that ends the phase has a clock, for anyone but the
singleplayer host: the terminal
packet closes the listener, which stops sending keep-alives and allows
fifteen seconds for the answer (the
connection). A slow configuration
can be the server still finding chunks for the player, and the queue lets it
be.
Play, and the way back
The play protocol is installed in two halves on each side: the server swaps
outbound early in the finish handler and inbound inside
PlayerList.placeNewPlayer; the client, in one handler, swaps inbound, sends
the finish packet, then swaps outbound. That is why the server’s side already encodes play
while still nominally in the configuration listener, and a refusal at the
finish goes out as a play packet. Chat session
keys are not part of any of this: they are negotiated in play, after the
client learns the server’s mode from ClientboundLoginPacket
(chat and signing).
A reconfigure does not re-run configuration.
ServerGamePacketListenerImpl.switchToConfig removes the player from the
world, sends ClientboundStartConfigurationPacket and swaps outbound; the
client’s ClientPacketListener.handleConfigurationStart flushes its chat
queue, stashes the chat state and carries the registry access, feature
flags, brand and server links forward in a rebuilt cookie, then answers
ServerboundConfigurationAcknowledgedPacket; and
ServerGamePacketListenerImpl.handleConfigurationAcknowledged installs a
configuration listener without calling
ServerConfigurationPacketListenerImpl.startConfiguration. No registries,
tags, feature flags or brand are re-sent, and none need to be. The player
parks in configuration with an empty queue until
ServerConfigurationPacketListenerImpl.returnToWorld re-queues the spawn
and join tasks. Both directions are reachable in vanilla only from
DebugConfigCommand, which the game registers only in a dedicated server’s command set, and only when started with its debug system properties.
Cookies, transfers and the chat reset are proxy hooks
A
ServerboundCookieResponsePacket arriving at any server listener is an
unexpected query and a disconnect, and nothing in the tree constructs
ClientboundStoreCookiePacket or ClientboundCookieRequestPacket. Of the
transfer machinery only ClientboundTransferPacket has a vanilla caller,
/transfer: it sends the client to another server, which sees a
ClientIntent.TRANSFER handshake and a transferred flag in its cookie, and
ClientCommonPacketListenerImpl.shouldHandleMessage keeps accepting
store-cookie and transfer packets while a transfer is in flight, which is
what lets a proxy’s trailing state land. ClientboundResetChatPacket is
registered and handled and never sent. The rest is fully implemented on the
client and unused by the server.
For a 1.21-era reader. The assumption that “packet handlers run on the game thread” is exactly backwards for the first two phases: the handshake and login listeners’ handlers run to completion on the Netty thread, and the first
PacketUtils.ensureRunningOnSameThreadin a connection’s life is in configuration.
Where to look
Read the four listeners, the server’s three in the order a login meets them,
and the whole page is in them. ServerHandshakePacketListenerImpl is
little more than one switch. ServerLoginPacketListenerImpl is the one to spend time on: read
ServerLoginPacketListenerImpl.tick first, then the handlers below it, and
the volatile state field above both will explain itself.
ServerConfigurationPacketListenerImpl is the task queue, and
ServerConfigurationPacketListenerImpl.handleConfigurationFinished is where
the player is finally built. ClientHandshakePacketListenerImpl is the
other side of the encryption exchange and worth reading against the server’s.
Then the tasks themselves — ConfigurationTask is a three-method
interface, and PrepareSpawnTask and JoinWorldTask are the two that
matter here; SynchronizeRegistriesTask is the reason the phase exists
and is Part II’s subject as much as this page’s.
Two doors this page only points at: ConnectionProtocol, which takes ten
seconds to read and is worth it for how little is in it, and Crypt, the
key, digest, cipher and salt helpers the login and chat signing share, in one
utility class.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
What the client is told
Verified against Minecraft 26.3 · Part IX · a creeper walks into view: everything the server decides to say, in order, and everything it decides not to.
A creeper three chunks away steps across a section boundary, and at the next tick’s tracking sweep, unless the player moves first, the server decides a player may know about it. One bundled packet goes out, and the position in that packet is not where the creeper is. It is where the creeper’s tracker last said it was — up to an update interval out of date. That is not a bug being tolerated. Every relative move the viewer is sent after it is a difference from the last position it was told, not a position, and the viewer adds each difference to its own copy of that base — so a viewer that started from anywhere else would be wrong by the gap until the next absolute position reset it. So the server sends the base rather than the truth, and the rest of this page is that trade made over and over: which chunks a player is sent and how fast, which entities they are told about, and what counts as a change worth a packet.
The cast
| class | what it decides | thread |
|---|---|---|
ChunkMap | which players can see which chunks and which entities — it keeps the tracked entities and moves each player’s chunk view | Server |
ChunkMap.TrackedEntity | one entity’s audience, ChunkMap.TrackedEntity.seenBy, and the range test that fills it | Server |
ServerEntity | the change detector: the baseline, the interval gate, the packet shape | Server |
ChunkTrackingView | which chunks a player is owed, and what a movement turns into | Server |
PlayerChunkSender | how many chunks leave for one player this tick | Server |
ChunkHolder | the per-chunk block-change batch, one flush a tick | Server |
EntityType | the per-type tracking range, update interval and delta exclusion | static, from the builder |
ChunkBatchSizeCalculator | the client’s answer — how many chunks a tick it wants | client Netty thread, and it never hops |
One entity’s tick, and the gates it does not pass
flowchart TD
TICK["ChunkMap.tick walks every tracked entity"]:::server --> SEC{"changed section"}
SEC -- yes --> UP["re-test every player in the level"]:::server
UP --> G1{"gate 1: range, hook, chunk"}
G1 -- "all three hold" --> IN["joins the audience: the introduction bundle"]:::server
G1 -- "any one false" --> OUT["leaves it: ClientboundRemoveEntitiesPacket"]:::server
SEC -- no --> G2{"gate 2: section, flag, or range"}
IN --> G2
OUT --> G2
G2 -- "any one is enough" --> SC["ServerEntity.sendChanges"]:::server
G2 -- "none of the three" --> MUTE["nothing, and the counter does not advance"]:::server
SC --> FREE["three feeds that skip gate 3"]:::server
SC --> G3{"gate 3: interval, flag, or dirty data"}
G3 -- "any one is enough" --> D["zero to five packets"]:::server
G3 -- "none of the three" --> WAIT["wait for a later call"]:::server
The three gates, each a three-term test: gate 1 is a conjunction and gates 2 and 3 are disjunctions. That is the asymmetry to carry away — one wrong answer at gate 1 keeps a viewer from seeing the entity at all, and one right answer at either of the others lets it speak, if it has anything to say.
The figure is the page. Three gates stand between an entity moving and a player hearing about it, and each is a three-term test: gate 1 is a conjunction, where all three must hold, and gates 2 and 3 are disjunctions, where any one term is enough. What comes out the bottom is not a description of the entity but a description of the difference between the entity and what this viewer was last told. The sections below are one per gate, then one per feed that does not go through them at all.
Gate 1: who is allowed to see it
ChunkMap.tick re-tests visibility only when something moved between
sections — the entity’s own section against the tracker’s last, and, for
players who changed section, every entity against that player. ChunkMap.move,
which runs on every move a player makes, re-tests every entity against that
player at once, and moves its chunk view when it crosses a boundary (tickets
and
loading).
The test itself is
three conjuncts.
The distance is horizontal. ChunkMap.TrackedEntity.updatePlayer compares
squared x/z distance against the smaller of the entity’s effective range and
the player’s view distance in blocks. Y is ignored entirely — an entity
directly above you, at any height, is in range.
The range is the maximum over the entity and everything riding it.
ChunkMap.TrackedEntity.getEffectiveRange takes the largest
EntityType.clientTrackingRange among the entity and all its indirect
passengers, then scales it by MinecraftServer.getScaledTrackingDistance,
which both server classes override: DedicatedServer applies the
entity-broadcast-range-percentage property, and IntegratedServer applies
the client’s own Entity Distance video option. In singleplayer a graphics
slider decides how far away mobs are tracked.
And the chunk must already be there. Entity.broadcastToPlayer looks like
a per-entity hiding hook and is not: it defaults to true and is overridden
exactly once, by ServerPlayer, to keep a spectator out of every
non-spectator’s view — and out of a fellow spectator’s while it is looking
through something else’s eyes. And
ChunkMap.isChunkTracked is false while the chunk is still queued in
PlayerChunkSender, so an entity is never sent before the ground it stands
on — the ordering is guaranteed rather than hoped for.
One case never reaches the test at all: ChunkMap.TrackedEntity.updatePlayer
returns immediately for the player’s own entity. You never track yourself.
That is why ServerEntity.Synchronizer has a self-directed verb,
ServerEntity.Synchronizer.sendToTrackingPlayersAndSelf, beside
ServerEntity.Synchronizer.sendToTrackingPlayers and
ServerEntity.Synchronizer.sendToTrackingPlayersFiltered, and why damage,
knockback and synched data must all reach for it.
The introduction is one bundle
What gate 1 opening costs, in packets, is one:
sequenceDiagram
participant CM as ChunkMap
participant CMTE as ChunkMap.<br/>TrackedEntity
participant SE as ServerEntity
participant CPL as ClientPacketListener
CM->>CMTE: the creeper changed section, so re-test every player
CMTE->>CMTE: all three conjuncts hold, so the audience gains this connection
CMTE->>SE: addPairing
SE->>SE: sendPairingData fills one list, in a fixed order
SE->>CPL: one ClientboundBundlePacket
Note over SE,CPL: add-entity at the tracker baseline, then synched data, attributes, equipment, ClientboundSetPassengersPacket, ClientboundSetEntityLinkPacket
CPL->>CPL: the bundle is applied inside one task, so nothing renders half-built
Gate 1 opening, and what the new viewer is sent the moment it does: one bundle, in one fixed order, applied on the client inside a single task. The position in it is the tracker’s baseline, not the creeper’s.
ServerEntity.sendPairingData produces the list and ServerEntity.addPairing
sends it as a single ClientboundBundlePacket, so the creeper can never be
seen half-initialised. The synched values are not read fresh: they come from
ServerEntity.trackedDataValues, the cached snapshot of the entity’s
non-default values, refreshed whenever dirty data is flushed
(synched entity
data).
Only the syncable
attributes go (attributes),
and equipment,
passengers and leash links go only if there are any.
That bundle is where the page’s claim first bites: what it carries is the
tracker’s baseline, not the creeper. It has three exceptions and two refusals. Paintings, item frames and leash knots build their own
ClientboundAddEntityPacket from their real position, bypassing
ServerEntity’s baseline — every concrete BlockAttachedEntity but the
cushion, which takes the default, and a block-attached entity has no moves
to add up.
EnderDragonPart refuses outright, and ChunkMap never asks it. Marker throws
outright if anyone asks — which nobody does, because its tracking range is
zero and ChunkMap never tracks it. Everything else reads its position from
ServerEntity.getPositionBase and its rotations and motion from the last-sent
fields, however old they are.
Gate 2: whether the detector is called at all
ChunkMap.tick calls ServerEntity.sendChanges when any of three things is
true: the entity changed section, Entity.needsSync is set, or its chunk is in
entity-ticking range. Two public fields on Entity do the forcing.
Entity.needsSync appears at this gate, again at gate 3 and again in the
velocity decision — three of the cascade’s terms are the same flag — and is
set by being pushed, by being loaded from disk, and by over twenty other
classes for their own reasons. Entity.syncPosition is subtler: it re-phases the call
counter to the next interval boundary, so a bounced entity syncs at once
rather than up to an interval late, unless it is a living entity other than a
shulker, whose SteppedInterpolationTracker takes the flag first and records
the bounce as a step.
The interval itself is per type and the page leans on it below, so it is
worth a number: EntityType.updateInterval defaults to three ticks, thirty
types give it another number and eight never let it open at all — and the
direction is the opposite of the obvious one. The three Display entities get one, because they move by
interpolation and a missed tick shows; an arrow, an experience orb, a dropped
item and a falling block get twenty, because a ballistic path is something
the client can extrapolate exactly. The thing moving fastest is told least
often.
The two counters inside ServerEntity are deliberately out of step.
ServerEntity.tickCount advances on every call, gate 3 open or shut, so
ServerEntity.FORCED_POS_UPDATE_PERIOD counts calls.
ServerEntity.teleportDelay advances only inside gate 3, so
ServerEntity.FORCED_TELEPORT_PERIOD counts gated calls — the forced
absolute sync is rarer than the forced position packet by however long the
interval gate stays shut.
This is also where a distant entity goes quiet, and it has nothing to say: a
mob outside entity-ticking range is not ticked either, so it does not move
itself, and the gate spares a call that would usually send nothing. A push from outside sets
Entity.needsSync and opens it again.
Gate 3, and the position it chooses
The first three rows are the four terms of the source’s one conjunction, in
its order, Entity.getRequiresPrecisePosition first. A relative packet must
pass all four, and only then is the delta’s size tested, which is why the
fourth row’s absolute sync, unlike the others, resets neither the teleport
count nor the ground flag. A passenger, the last row, is branched off before
any of them.
| condition | result |
|---|---|
Entity.getRequiresPrecisePosition | absolute sync |
ServerEntity.teleportDelay past ServerEntity.FORCED_TELEPORT_PERIOD — four hundred gated calls, so about 1,200 ticks for an entity tracked every tick on the three-tick default interval, fewer when data or a push opens the gate | absolute sync |
| the entity just dismounted, or its ground flag differs from the one the conjunction last recorded — so a landing or a step off a ledge that a gated call sees costs a full packet | absolute sync |
| delta beyond what a short can hold — about eight blocks — or, for a dropped item against a wall, floor or ceiling, one the encoding would round | absolute sync |
none of those, and the squared position delta is below ServerEntity.TOLERANCE_LEVEL_POSITION with rotation within ServerEntity.TOLERANCE_LEVEL_ROTATION | nothing sent |
| none of those, and something moved | ClientboundMoveEntityPacket.Pos, .Rot or .PosRot |
the call count is a multiple of ServerEntity.FORCED_POS_UPDATE_PERIOD | a position packet even if nothing moved — but only on a call that got through gate 3 |
| the entity is a passenger | rotation only — the base is silently re-set, and the first call to open gate 3 after it dismounts forces an absolute sync |
The gate covers more than the position. The same test that opens the table
above also opens the synched-data flush, which is why shearing a sheep runs
that sheep’s position branch in the same call (synched entity
data has
the data channel’s half); the ItemFrame branch — one section down, under the feeds that skip gate
3 — is the only path to that flush which skips the interval test, and
ServerEntity.handleMinecartPosRot reaches it from inside the gate rather
than around it.
Rotations are single bytes, so one unit is a little over a degree, and the
base — a VecDeltaCodec, which is the object holding the last-sent position
the whole page’s opening rests on — advances only when a position is sent, on
both sides alike: that is what keeps the two sides’ arithmetic identical, and
the two places that re-set the server’s without one, a passenger’s and a
new-behaviour minecart’s, are the two whose next position goes whole. An arrow never takes a partial
path — AbstractArrow is excluded from the position-only and rotation-only
branches, so every open gate sends it a full position-and-rotation packet. A
minecart on the new movement behaviour skips the table altogether:
ServerEntity.handleMinecartPosRot diverts it into
ClientboundMoveMinecartPacket, which carries the minecart’s own list of
steps.
That first term is set by three classes in the whole game.
Entity.setRequiresPrecisePosition is asked for by a happy ghast on its still
timeout — a large ridable platform, whose rounding error a player has to stand
on — and by a falling block or lit TNT while it is touching a block.
Velocity is the third decision and a separate channel. When the entity wants
deltas — it is in the EntityType.trackDeltas set, Entity.needsSync is set,
or it is a LivingEntity currently elytra-flying — ServerEntity compares the
current delta movement against ServerEntity.lastSentMovement and sends
ClientboundSetEntityMotionPacket, bundled with
ClientboundProjectilePowerPacket for a hurtling projectile.
EntityType.trackDeltas is a third tracking parameter, switched off on the
builder by EntityType.Builder.dontTrackDeltas: players, llama spit, the
wither, bats, item frames, leash knots, paintings, end crystals, evoker fangs
and cushions are out, everything else is in.
Three feeds that skip gate 3, and one that skips ServerEntity
ServerEntity.sendChanges opens with Entity.updateDataBeforeSync, the hook
LivingEntity overrides to reconcile its own effects — so a mob effect that
expired this tick can dirty the synched container and open gate 3 in the same
call that goes on to read it (synched entity
data).
Below it, three feeds ignore gate 3, a smaller exemption than it sounds:
all three live inside ServerEntity.sendChanges, so a mob that fails gate 2
gets none of them either. Entity.syncVelocity
sends a motion packet to the trackers, and to the entity itself when it is a
player, which is why a player feels knockback at once, its own client moving
it; a creeper, which a client only interpolates, shows its knockback with its
next position packet. A changed passenger list is diffed on every call and
goes out as a ClientboundSetPassengersPacket to every tracker, a riding
player included, though ServerPlayer.startRiding or
ServerPlayer.removeVehicle has already told that player directly. An
ItemFrame flushes its synched data every tenth call, and if it holds a map
with saved data it walks every player in the level — not its trackers — to
push map updates.
Equipment changes do not pass through ServerEntity.
ClientboundSetEquipmentPacket comes from
LivingEntity.handleEquipmentChanges, and it goes to the trackers without
the self-directed variant, so a player is never sent their own equipment. A
straight main-hand to off-hand swap does not even get that far:
LivingEntity.handleHandSwap compresses it into a one-byte entity event. And
damage crosses without a number — ClientboundDamageEventPacket carries the
source, not the amount, and the health bar moves because of a separate synched
value (damage and death).
Chunks arrive on a loop the client paces
Chunk visibility is the same kind of decision one level up: made per player rather than per entity, and paced by a loop whose set point the client supplies.
Which chunks enter and leave
ChunkMap.applyChunkTrackingView diffs the player’s old and new
ChunkTrackingView and turns the difference into a queue of pending sends and
a run of ClientboundForgetLevelChunkPackets. Which chunks are in that view,
what shape the view is — neither a disc nor a square, because
ChunkTrackingView.isWithinDistance shrinks each axis before it squares — and
what makes a chunk eligible to leave at all belong to tickets and
loading.
The one class the iteration needs and no other page names is
ChunkTrackingView.Positioned, which walks one chunk beyond the view distance
for exactly that reason.
The rate the client asks for
Then, once per tick, PlayerChunkSender.sendNextChunks runs per player from
the phase MinecraftServer.tickChildren reaches after every level has ticked
(the server
tick):
- it stops if too many batches are unacknowledged —
PlayerChunkSender.maxUnacknowledgedBatchesstarts at one and is raised toPlayerChunkSender.MAX_UNACKNOWLEDGED_BATCHES, which is ten, on the first reply. One batch in flight until the client answers, ten after it: the first batch of a login is a hard round-trip barrier, and it is why the world arrives in a pause and then a flood; - it accumulates
PlayerChunkSender.batchQuotaby the client’s desired rate and stops if it is below one; - it takes that many chunks nearest first from
PlayerChunkSender.pendingChunks— or, on a memory connection, or whenever fewer are pending than the budget allows, all of them at once; - and it brackets them with
ClientboundChunkBatchStartPacketandClientboundChunkBatchFinishedPacket.
The client times the bracket in ChunkBatchSizeCalculator, clamps the sample
against the running average by ChunkBatchSizeCalculator.CLAMP_COEFFICIENT,
folds it into a weighted mean against
ChunkBatchSizeCalculator.MAX_OLD_SAMPLES_WEIGHT, and reports a rate back in
ServerboundChunkBatchReceivedPacket. The server clamps that between
PlayerChunkSender.MIN_CHUNKS_PER_TICK and
PlayerChunkSender.MAX_CHUNKS_PER_TICK and uses it as next tick’s budget. It
is a closed control loop, and it is the client that sets the set point.
Seven milliseconds of client time per tick is what
ChunkBatchSizeCalculator.getDesiredChunksPerTick divides by its running
estimate of nanoseconds per chunk. It starts pessimistic, at two milliseconds
a chunk — three and a half chunks a tick against
PlayerChunkSender.START_CHUNKS_PER_TICK, nine. That opening figure is never
sent as it stands: the client folds the first real batch into the average
before it answers, so the first number the server hears is already half
measurement.
What is being measured is narrower than it looks.
ClientPacketListener.handleChunkBatchStart and
ClientPacketListener.handleChunkBatchFinished are two of the nine handlers on
the client’s play listener that never hop off the network thread, so the
loop is timing the batch’s arrival and decode on Netty, not mesh building (the nine are listed in
threads).
What a chunk packet carries
A chunk packet — ClientboundLevelChunkWithLightPacket — carries the
client-facing heightmaps, every section’s counts, paletted block states and
biomes, a
block-entity entry per block entity holding BlockEntity.getUpdateTag rather
than its save data, and the light layers. See
chunk anatomy and
lighting.
Block changes: one flush a tick, two audiences
ServerLevel.sendBlockUpdated sends nothing. It marks a section dirty on the
ChunkHolder — in ChunkHolder.changedBlocksPerSection, one short set per
section — and adds the holder to a set on ServerChunkCache, unless the chunk
is loaded but not ticking, in which case nothing is recorded and the change is
never broadcast to anyone. ServerChunkCache.broadcastChangedChunks drains
that set through
ChunkHolder.broadcastChanges once per level tick, inside
ServerChunkCache.tickChunks (on a frozen tick too, though never in a debug
world), early in the tick and before entities
move (the level
tick)
— so one broadcast
carries the block changes made so far this tick and the rest since the last
broadcast: the previous tick’s block events, entities and block entities, and
players’ actions.
Light goes first, and to a narrower audience — the border of each
player’s sent region rather than everyone tracking the chunk, through
ChunkMap.isChunkOnTrackedBorder. Why the border player needs it and the
player in the middle does not belongs to lighting,
and it is the one feed of this flush whose audience is narrower than the
chunk’s trackers.
Then blocks, to everyone tracking the chunk. Exactly one changed block in
a section becomes a ClientboundBlockUpdatePacket, two or more become a
ClientboundSectionBlocksUpdatePacket, and every change within the tick
collapses into at most one packet per section in this flush. What collapses them is that the
set holds positions, not values: ChunkHolder.broadcastChanges reads the
level again when it builds the packet, so a position written five times in one
tick is sent once, carrying the value it ended on and none of the four it
passed through. A whole redstone cascade can therefore run, settle and be
broadcast as a single state per position (signal and
dust).
And block entities alongside the blocks, not after them. The check runs
inside the same per-section loop, immediately after that section’s own update
packet — interleaved, not a third pass.
ChunkHolder.broadcastBlockEntityIfNeeded tests whether the state has a block
entity at all and delegates to ChunkHolder.broadcastBlockEntity, one of the
two places in the game that ask BlockEntity.getUpdatePacket (the other
resends the blocks under a player the server thinks is standing on air). Only overriding types produce one here, and the fallback is not “it
rides the chunk packet instead” — which is why chest contents are invisible
until the chest is opened. What the two sync hooks default to, and which types
override which, belong to block
entities.
The rest of the block-shaped traffic
It is small: ClientboundBlockEventPacket from the deferred event set
(pistons and block
events),
ClientboundBlockDestructionPacket for other players’ mining progress,
ClientboundChunksBiomesPacket when biomes are re-sent, and
ClientboundBlockChangedAckPacket, sent at most once per connection per tick
and on any tick where a loaded client sent a block action, a use-on or a use —
including an unsequenced abort or change of dig direction, which produces an ack of zero and settles nothing. The rules that receipt obeys — and the ordering rule that makes a receipt
without a verdict sufficient — belong to prediction and
acknowledgement.
The level’s own feeds
Entities and chunks are the two big feeds. The level itself has several small
ones, all bypassing the change detectors entirely — most on ServerLevel,
though time comes from MinecraftServer and the view distances from
PlayerList:
- Time, once a second.
MinecraftServer.forceGameTimeSynchronizationruns every twentieth tick, and the packet it broadcasts carries the overworld’s game time and an empty clock map: clock state travels only when a clock is changed, a player joins, respawns or changes dimension, or the rule that advances time changes (what crosses the wire). - Weather, on change.
ServerLevel.advanceWeatherCyclebroadcasts rain- and thunder-level changes and the start/stop pair asClientboundGameEventPackets, andPlayerListre-sends the same set to a joining player. - Sounds, level events, particles and entity events, each with its own helper and its own audience. Two radii are worth naming because they are not the tracking distance: another player’s mining progress reaches everyone within thirty-two blocks except the miner, and a block event reaches sixty-four.
- View distances, as
ClientboundSetChunkCacheRadiusPacketandClientboundSetSimulationDistancePacket— the two integers that are the client’s entire knowledge of the ticket system, and the only feed here the receiver acts on structurally rather than by storing a value: the first one rebuilds the client’s chunk storage array. - The debug feed, which the next section’s table names as an exception
more than once, and a narrow one.
ServerLevelowns a set of per-subscriber debug synchronizers that push neighbour updates, POI state, chunk sends and entity tracking to a client that has opted in — sixteen subscriptions in all, registered inDebugSubscriptions, covering brains, goal selectors and paths among them. Most of the next section has no subscription at all: not the seed, not the loot tables, not the game rules, not a scheduled tick.
What the client is never told
| what never crosses | the server-side owner | where it is explained |
|---|---|---|
| all AI — targets, goals, brains, paths — bar a few synched flags and target ids (the aggressive flag, a guardian’s beam target, the wither’s head targets), except through the debug channel | Mob.goalSelector, Mob.targetSelector, Mob.getTarget, Brain, Mob.navigation | AI, goals and brains |
| scheduled block and fluid ticks — the client’s equivalents are empty | ServerLevel.blockTicks, ServerLevel.fluidTicks | scheduled ticks |
| points of interest, except through the debug channel | ChunkMap.poiManager | points of interest |
| the ticket graph — the client gets a radius and a simulation distance, as two integers | TicketStorage, DistanceManager, ChunkHolder.ticketLevel, FullChunkStatus | tickets and loading |
worldgen, and the world seed — ClientLevel gets only a biome zoom seed; structures only through the debug channel | ChunkGenerator, RandomState, ServerLevel.structureManager, StructureStart | the generation pipeline |
the worldgen heightmaps, and any block-entity field outside BlockEntity.getUpdateTag, bar an operator’s query, a command block’s screen, a creative player’s pick-block and an opened container’s slots | BlockEntity | block entities |
| non-syncable attributes, loot tables and loot seeds, the natural spawn state, raids and the dragon fight beyond their boss bars, except through the debug channel | AttributeMap, LootTable, NaturalSpawner | attributes |
| game rules — the full set reaches the client only on request, and only for a player with the command permission; the few that change how the client behaves are pushed | GameRules | level data and rules |
| the creeper’s fuse length and its swell counter — of its three synched values, none is the counter | Creeper | synched entity data |
| everything out of range: entities past tracking range, chunks past the view, and every other level on the server | ChunkMap, MinecraftServer.levels | — |
Choosing what the client may be wrong about
Every gate above is a decision not to say something, and the server can
afford them because the receiver is not passive. ClientLevel never admits a
chunk is missing, runs its own light engine and its own clock, ticks entities
it does not own — Creeper.tick runs locally, swell counter and all — and
guesses at block placements through a sequence-numbered ledger. That half of
the story is Part X’s: the client
level for
what the receiver fakes and simulates,
prediction and
acknowledgement
for what it
guesses, and
authority
for which side is allowed
to move what. The client also applies a whole burst of these packets once per
frame, before that frame’s ticks, so at a high frame rate it takes the
server’s updates far more often than it ticks — the two-loops figure in
anatomy is the shape,
the client loop the detail.
Which is the design constraint under all of it: because the client will happily simulate in the absence of data, the server’s job is not to keep the client correct — it is to choose what the client is allowed to be wrong about.
For a 1.21-era reader. Routine movement no longer travels as
ClientboundTeleportEntityPacket: that packet survives, butServerEntitynever touches it, and an absolute position isClientboundEntityPositionSyncPacket. And in 26.3 the passenger list goes to every tracker, where 1.21 skipped a player whose own riding had changed.
Where to look
ServerEntity.sendChanges is the page. It is one long method, and gate 3,
the head-yaw branch and the velocity branch are all visible in it at once,
with the relative-or-absolute conjunction one call away in
ServerEntity.createMovePacket; read it before anything else
here, and read it twice.
Then the two callers that decide who hears it: ChunkMap.tick for the
sweep that runs it, and ChunkMap.TrackedEntity.updatePlayer for gate 1,
which is short and the whole visibility policy. ServerEntity.addPairing
and ServerEntity.sendPairingData are the introduction bundle, in the
order it is built.
For the other two feeds: PlayerChunkSender.sendNextChunks is the control
loop from the server’s end and ChunkBatchSizeCalculator is the client’s
half of it — open them together or neither. ChunkHolder.broadcastChanges
is the block flush, and the line in it that re-reads the level is why a
redstone cascade costs one state per position.
One door this page only points at: VecDeltaCodec, the base itself and
the reason the whole policy works.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Chat and signing
Verified against Minecraft 26.3 · Part IX · A player presses T, types a line and hits enter: the message is signed on the way out, taken apart on the way in, and verified again by every client that holds the sender’s session.
A player presses T, types hey and hits enter. Before the line leaves the
machine, ChatScreen.normalizeChatMessage has squeezed the whitespace and cut
it to 256 characters, and ClientPacketListener.sendChat has taken a
timestamp, a random salt and the signatures of the messages the player saw
among the last twenty signed ones it was sent, and signed all of it with a key Mojang issued to that
account. The server pulls the packet apart on the Netty thread, hands the
cryptography to the Server thread, filters it, decorates it, broadcasts it —
and every receiving client that holds the sender’s session verifies the
signature again before drawing a character. Every one of those steps can say no, and no does not mean the
same thing twice. Forge a signature and you stay connected: you get a red line
and your chain dies, so everything else you say this session fails too.
Miscount which messages you have seen — a number nothing in the game shows
you — and the server closes the connection mid-sentence. The bookkeeping is
defended harder than the cryptography is, and that is the right way round.
The cast
| class | what it decides | thread |
|---|---|---|
ServerGamePacketListenerImpl | the order the checks run in, and which failure closes the connection | Netty for the window, the characters and the chat-visibility refusal, Server for everything after |
LastSeenMessagesValidator | whether the client’s twenty-slot acknowledgement still matches the server’s mirror | Netty, and Server for the pending count — inside a lock on itself |
SignedMessageChain.Decoder | whether this message continues the sender’s chain — and whether the chain survives the answer | Server |
PlayerChatMessage | what a signature covers: the chain link, the content, the timestamp, the salt, the window | wherever a message is built |
MessageSignatureCache | which of those signatures travel as a small index instead of 256 bytes | both sides, one 128-slot cache each |
PlayerList · OutgoingChatMessage | who gets a copy, and whether it goes as a signed message or a disguised one | Server |
SignedMessageValidator.KeyBased | whether the receiving client believes the sender said this | Render |
ChatTrustLevel | secure, modified or not secure — the tag drawn beside the line | Render |
A message is not the text you see
Two packets carry all of this, and the page names them once here: a line
leaves its author in a ServerboundChatPacket and reaches everybody else in a
ClientboundPlayerChatPacket, which is the one packet in the game whose
contents a recipient checks a player’s signature over before drawing.
A message on the wire is a PlayerChatMessage: a SignedMessageLink saying
where in the sender’s chain it sits, a MessageSignature, a
SignedMessageBody of exactly four fields, and — optionally — a Component
to display instead of the signed string. That Component is Part II’s
subject (text
components); all this page
needs from it is that it is a different object from the signed text and that
the signature does not cover it. Vanilla’s decorator never produces one:
MinecraftServer.getChatDecorator is hard-coded to ChatDecorator.PLAIN, so
PlayerChatMessage.withUnsignedContent always finds the decorated copy equal
to the original and drops it — that method keeps a copy only when it differs
from the signed text. Vanilla still reaches it by another road:
MessageArgument.resolveChatMessage, behind the message-shaped commands, hands
it the resolved text, which differs exactly when a selector expanded. Type a
selector into a whisper and the recipient sees a string the signature does not
cover — the security half of a mechanism the Commands section below picks
up.
One line, typed and delivered
sequenceDiagram
box transparent the sender's client
participant CScr as ChatScreen
participant CPL as ClientPacketListener
end
box transparent the server
participant SGPL as ServerGamePacket<br/>ListenerImpl
participant PL as PlayerList
end
box transparent every recipient's client
participant RCPL as ClientPacketListener
participant CLis as ChatListener
end
CScr->>CScr: normalizeChatMessage squeezes the whitespace, cuts to 256
CScr->>CPL: sendChat
CPL->>CPL: a timestamp, a salt, the last-seen window, then the signature
CPL->>SGPL: ServerboundChatPacket
SGPL->>SGPL: Netty thread, apply the last-seen update, check the characters
rect rgba(0, 0, 0, 0.04)
Note over SGPL,PL: the rest of the server's work, all of it on the Server thread
SGPL->>SGPL: SignedMessageChain.<br/>Decoder.unpack, the signature check
SGPL->>SGPL: the filter starts, the decoration is immediate, a FutureChain joins them
SGPL->>PL: broadcastChatMessage, bound to ChatType.CHAT
end
PL->>RCPL: ClientboundPlayerChatPacket, cached last-seen signatures sent as ids
RCPL->>RCPL: the global index, then the cache ids, then the signature
RCPL->>CLis: handlePlayerChatMessage: the delay queue, then trust level and blocklist
CLis->>RCPL: markMessageAsProcessed
RCPL->>SGPL: ServerboundChatAckPacket, once the offset passes 64
One line’s whole journey, three machines in three boxes: the two
ClientPacketListener lanes are the sender’s and every recipient’s, and the
same signature is checked once in the middle box and again in the right-hand
one. The shaded band is the server’s work on the Server thread, which ends in the
arrow that leaves it.
Five things in that picture are worth naming before the checks are.
The client signs the conversation, not just the sentence. The window it signs is the window it now treats as acknowledged, so the signature binds the context the sender had in front of them — which is what makes a report show what a message was a reply to.
The hop is deliberate. ServerGamePacketListenerImpl.handleChat never
calls the usual same-thread guard (the
connection has what that
guard normally does): the window and the character check run on
the Netty thread, and only then does
ServerGamePacketListenerImpl.tryHandleChat post the rest to the server. That
posted task, and the FutureChain continuation that joins the text filter to
it, drain with every other queued server task — so a slow filter service
delays delivery by however many ticks it takes
(the server tick).
The filter is an HTTP service, and usually there is none. TextFilter is
the per-player interface every chat line — and every signed command message,
book page, sign line and anvil name — passes through, and MinecraftServer.createTextFilterForPlayer
hands out TextFilter.DUMMY, which passes everything, unless the server is a
dedicated one whose text-filtering-config property names an endpoint.
ServerTextFilter.createFromConfig reads that property at startup (starting a
server) and
builds either a LegacyTextFilter or a PlayerSafetyServiceTextFilter
depending on the configured version, each a pool of Chat-Filter-Worker
threads posting JSON to that endpoint. What comes back is a FilteredText —
the raw string and a FilterMask of which characters were objected to — so
the filter is never destructive here and never binary: the mask travels with
the message and is applied per recipient, later. Filterable is the same idea
frozen into saved data, a raw value beside an optional filtered one.
Decoration is not sequenced after filtering. The handler starts the filter future, decorates immediately and synchronously, and only then registers the continuation that joins the two. A decorator never sees filtered text.
Broadcast is per recipient, and decided in three places.
PlayerList.broadcastChatMessage logs the line — marked Not Secure by
PlayerList.verifyChatTrusted if it has no signature or has expired — and
then asks the sender’s ServerPlayer.shouldFilterMessageTo of each player in
turn. ServerPlayer drops the copy unless that player’s setting is
ChatVisiblity.FULL; OutgoingChatMessage.Player applies the filter mask to a
filtered copy and skips one that was filtered away entirely, and PlayerList
tells the sender once if any was. A message whose
sender is Util.NIL_UUID is a system message and leaves as an unsigned,
unreportable ClientboundDisguisedChatPacket instead.
Three ways to say no, and one way not to ask
Everything above happens to a message that survives. Five of the checks run before the server thread ever sees the packet, and where a refusal lands decides how much of the session it takes with it.
flowchart TD
P["ServerboundChatPacket"]:::netty --> N{"the window, the characters, chat visibility"}:::netty
N -- "the window, or a character" --> XC["the connection dies"]
N -- "chat turned off" --> XM["the message dies, and the next one may still land"]
N -- "all five pass" --> S{"SignedMessageChain.<br/>Decoder.unpack"}:::server
S -- "no signature, or key expired" --> XM
S -- "out of order, or signature invalid" --> XB["the chain dies, and every later message this session with it"]
S -- accepted --> OK["filter, decorate, broadcast"]:::server
The five checks on the Netty thread, in green, and the Server thread’s work, in blue, drawn so that the arrows meet at the three things a refusal can kill. The middle terminal is reached from both, which is the picture’s point: where a check runs says nothing about what it costs.
Those three endings are the whole vocabulary of failure here, and every check in the next section lands on exactly one of them.
The message dies alone: it is dropped, the sender usually gets a red
system line explaining why, and the next thing they send is judged on its own
merits. The chain dies for the session: SignedMessageChain clears the
link it was going to advance, and from then on no unpack can succeed — the
chain broken error if the message is otherwise well-formed, and a missing-key
or expired-key error before that if it is not. Only a
new session key, announced with ServerboundChatSessionUpdatePacket, or a new
connection restores it. The connection dies immediately — through the same funnel as any other
fault (the connection) — and the
player is back at the multiplayer list.
And there is a fourth way, which is why the flowchart starts where it
does. A client can refuse before the server is ever asked. ChatAbilities
is a client-side permission set the server has no part in, stripped
independently by game options, launcher policy and the account profile, and it
decides whether this client will send or accept anything at all
(permissions).
Nothing below applies to a message that was never sent, and a player in that
state sees no error from the server because there was no exchange to fail.
ChatVisiblity is the sliver of the same idea the server is told about, and
it has three values, not two — ChatVisiblity.HIDDEN still lets action-bar
text through.
Every check, and what it costs
The first fifteen rows are the server treating the client as the adversary. The last three are the client treating the server as one — the same design mirrored, because a server can lie about who said what at least as easily as a client can.
The first nine rows are in the order a chat line meets them, and the first
five run on the Netty thread, before the message is handed to the server at all: the
three window checks inside
ServerGamePacketListenerImpl.unpackAndApplyLastSeen, then the character
check and the chat-visibility refusal inside
ServerGamePacketListenerImpl.tryHandleChat. Four of those five close the
connection and the fifth does not, which is the split the flowchart above
draws; rows six to nine happen inside the task
ServerGamePacketListenerImpl.tryHandleChat posts to the server, and the rate
check in the continuation that broadcasts. The
rest are other paths: a command, each recipient’s copy, a new session key, and
the receiving client.
| the check | what it catches | what dies |
|---|---|---|
LastSeenMessagesValidator.applyOffset, from a chat packet or a bare ServerboundChatAckPacket | a client advancing its window past messages the server has not sent it | connection |
LastSeenMessagesValidator.applyUpdate, the acknowledged bits | a bit set longer than twenty, one naming a slot the server does not hold, or one un-acknowledging a slot already acknowledged | connection |
LastSeenMessages.Update.verifyChecksum | the two sides holding different signatures in slots whose bits agree — a desync the crypto would otherwise report as a bad signature | connection, unless the client sent LastSeenMessages.Update.IGNORE_CHECKSUM |
ServerGamePacketListenerImpl.isChatMessageIllegal, over StringUtil.isAllowedChatCharacter | section signs and control characters — formatting injected into everyone else’s chat | connection |
ServerPlayer.getChatVisibility, non-commands only | a player who turned chat off and sent a line anyway | message, with a red chat.disabled.options back to the sender |
SignedMessageChain.Decoder.unpack, no signature present | an unsigned message once a chat session exists — unconditionally, whatever MinecraftServer.enforceSecureProfile says: that flag decides what the decoder used before a session exists does with an unsigned message, and separately gates the unsigned-command refusal six rows down | message |
the same, ProfilePublicKey.Data.hasExpired | a session key past its expiry still being used to sign | message |
| the same, a timestamp before the last accepted one | a replayed or reordered message from this sender | chain |
the same, PlayerChatMessage.verify | content, timestamp, salt or window that do not match the signature — a forgery, or a proxy editing text in flight | chain |
ServerGamePacketListenerImpl.collectSignedArguments, an unknown argument name | a client signing arguments of a command the server’s own parse does not have | chain, broken explicitly |
| the same, a signable argument with no signature | signatures stripped from some arguments of a signed command | message — the chain is left intact |
ServerGamePacketListenerImpl.performUnsignedChatCommand | a signable command sent down the plain command packet with its signatures removed | message, and only when MinecraftServer.enforceSecureProfile is on |
ServerGamePacketListenerImpl.detectRateSpam, a TickThrottler per player | flooding: each message costs 20, one point decays per tick, and the ceiling is twenty per configured second — chat-spam-threshold-seconds, ten by default, so 200 points, or about ten messages sent faster than the decay | connection, except for operators, and on an integrated server for anyone, its threshold being zero |
ServerGamePacketListenerImpl.sendPlayerChatMessage, via LastSeenMessagesValidator.trackedMessagesCount | a client that is sent signed messages and never acknowledges them | connection, past 4,096 pending |
ServerGamePacketListenerImpl.handleChatSessionUpdate | a key that expires earlier than the one it replaces, or one RemoteChatSession.Data.validate cannot trace to Mojang’s services key | connection |
ClientPacketListener.handlePlayerChat, the global index | a server dropping, duplicating or reordering messages beneath the player, which would falsify any report drawn from the log | connection |
MessageSignature.Packed.unpack against the client’s cache | a server naming a cached signature the client has never held | connection |
SignedMessageValidator.KeyBased — expired key, failed signature, or a link that is not SignedMessageLink.isDescendantOf the last | a server inventing lines in another player’s name | chain, latched: the validator never returns to valid |
Two rows want a sentence more. The checksum is the only check in the table a
client may decline: LastSeenMessages.Update.verifyChecksum passes anything
when the byte is zero, and a real checksum that computes to zero is bumped to
one so it can never be mistaken for the opt-out — though the vanilla client
never opts out, because LastSeenMessagesTracker.generateAndApplyUpdate
always computes one. And chain broken is a latch on both sides: the server’s
SignedMessageChain and the receiving client’s
SignedMessageValidator.KeyBased both refuse everything afterwards, so one
bad signature costs a sender their voice until a new key or a new connection, not one line.
Why losing the window is worse than losing the signature
The asymmetry looks backwards until you notice what the window is for. The
last-seen list — twenty LastSeenTrackedEntry slots on the tracker — is
signed in full but sent as a bitset over a window the server mirrors, and
passed on to other clients as MessageSignature.Packed entries, each an index
into a cache both ends keep in step where that cache holds the signature. If the server’s mirror ever disagrees with the
client’s window, the server reconstructs a different SignedMessageBody, and
every signature after that fails for a reason no cryptographic error message
can explain. There is no recovery from inside: the state is shared, and half of it
is wrong.
So the game ends the connection at the first sign of that divergence, and
LastSeenMessagesValidator is written to be suspicious. It rejects an
acknowledgement of a slot it does not hold, an un-acknowledgement of a slot
it already acknowledged, a negative offset, an offset larger than the number
of tracked messages outside the window it has
sent, and a bit set wider than the window; a checksum mismatch on top
of all that says the client and server must have desynced in as many words.
A bad signature is the opposite kind of problem: local, provable and attributable. One sender is misbehaving, everyone else’s conversation is intact, and the proportionate answer is to stop trusting that sender — not to end a session for every other player in the room.
What keeps the two caches identical is that neither end chooses what goes in.
MessageSignatureCache.push takes a message’s body and its signature, builds
a queue of the last-seen signatures with that signature behind them, and
writes them into 128 slots newest-first, shuffling back any displaced entry
the new batch did not already contain. It is called in exactly two places, and
they are the two ends of one connection: the server as it sends a signed
message to that player, and that player’s client as it handles the packet.
Same input, same method, same order — which is why an index means the same
thing at both ends, and why nothing can put them back in step once they are
not.
Sixty-four messages is the acknowledgement offset a client may
accumulate before ClientPacketListener.markMessageAsProcessed sends a bare
ServerboundChatAckPacket unprompted. That packet exists so that a player who
only listens never reaches the server’s 4,096 pending messages and gets
dropped for saying nothing all evening.
What the signature covers
PlayerChatMessage.updateSignature feeds the signer a version constant, then
the link — sender id, session id, index — then the body: the salt, the
timestamp in seconds, the length of the content, the content bytes, the
count of last-seen signatures and each one’s raw bytes. Not fed to it: the
decorated Component, the FilterMask, the ChatType.Bound that supplies the
someone said wrapper — a ChatType is a data-pack registry entry and
ChatTypeDecoration is the translation key and argument list that turns a
message into so-and-so said this, so the phrasing around a line is data and the
line is not — and the global index the receiving client checks. A
server is free to change any of those. The signature is over what the player
typed and what they had seen when they typed it, and nothing else.
On the receiving client all of that lands in ChatListener, the class the
figure at the top of this page ends on. ChatListener.handlePlayerChatMessage
records a signed message in the client’s window whether or not it is drawn (a
refused one as an empty slot, never acknowledged) and puts the line, with that
bookkeeping, in ChatListener.delayedMessageQueue rather than on the screen
when the chatDelay option is set — a queue its own tick drains one line at a
time, which is why the option is a client-side stagger and not a server one.
That gap is what ChatTrustLevel exists to expose, and it tests for it
crudely on purpose. ChatTrustLevel.evaluate calls a message modified the
moment the rendered string does not contain the signed string — the limb
that catches a server rewriting what someone said. Only if that passes does it
look at style, and only inside the unsigned copy — which vanilla sends only for
a command message whose selector expanded, and that copy has already failed
the contains test. A
message with no signature at all, or one older than seven minutes, is not
secure instead. The tag is normally all that happens; with
Options.onlyShowSecureChat on, a not-secure message is discarded rather than
drawn — that test runs first, and Minecraft.isBlocked and
Minecraft.isFriendOnlyRestricted can swallow what survives it.
The session key is signed one level up. ProfilePublicKey.Data carries an
expiry, the public key and a signature over the profile id, the expiry in
milliseconds and the encoded key, checked against Mojang’s services key. The
receiving client allows ProfilePublicKey.EXPIRY_GRACE_PERIOD — eight hours —
that the signing chain on the server does not.
The private half lives nowhere on disk. LocalChatSession holds the pair
for as long as a session lasts, and AccountProfileKeyPairManager writes the
key file only when SharedConstants.IS_RUNNING_IN_IDE and deletes it
otherwise, so a shipped client re-fetches from the account service every
launch — and only if ClientboundLoginPacket.onlineMode said the server was
in online mode, which is the packet that opens the play phase (protocol
phases).
Commands: one signature per argument
ClientPacketListener.sendCommand parses the command locally and builds a
SignableCommand. If nothing in it needs signing it sends
ServerboundChatCommandPacket, which carries the string and nothing else. If
something does, it sends ServerboundChatCommandSignedPacket with
ArgumentSignatures: one signature per argument, each consuming its own chain
index, all of them sharing one timestamp, salt and window. Signable means
the argument type implements SignedArgument, and in 26.3 exactly one type
does — MessageArgument, behind the message-shaped commands, and the
enumeration of which commands register it belongs to brigadier and
commands.
MessageArgument is also the one route by which chat text has its selectors
expanded, which is how a command message comes to carry unsigned content at
all: the expansion itself, its permission and its parse belong to text
components.
What matters here is only that the expanded string is the one the recipient
reads and the signature does not cover it.
The server re-parses its own copy and looks each signature up by argument
name, which is where two rows of the table above come from: a name its parse
does not have breaks the chain outright, while a signable argument the client
left unsigned only fails the command. Both sides cap the shape of the packet
at ArgumentSignatures.MAX_ARGUMENT_COUNT — eight — and
ArgumentSignatures.MAX_ARGUMENT_NAME_LENGTH, sixteen. Commands run against their own TickThrottler, separate from chat’s and built
the same way from command-spam-threshold-seconds, which also defaults to
ten — so a player has two independent budgets and can spend both. A
command message sent down the plain command packet, where secure profiles are
not enforced, is broadcast as a
ClientboundDisguisedChatPacket: chat-type decorated, unsigned, unreportable.
Questions players ask
What makes the “Not Secure” tag appear? No signature, or a timestamp more than seven minutes old by the receiving client’s clock. The server calls the same message expired after five, and logs it as Not Secure there too. Two machines whose clocks are a few minutes apart will flag perfectly honest messages, and the server says so in its log.
Why does a custom font not flag every line on my server? Because the style test only looks inside the unsigned copy, and vanilla’s decorator never produces one. For ordinary chat the font check is therefore dead, and it comes alive only on a server that decorates. A player’s own lines on an integrated server skip both tests and are secure by definition.
Why can I report some lines and not others? LoggedChatMessage.canReport
needs a signature from the player being reported, and system messages,
disguised command output and anything a broken chain swallowed carry none.
What a report uploads is the signed material — index, session id, timestamp,
salt, the last-seen signatures and the signed content — so it can be
re-verified independently, with
ChatReportContextBuilder.collectAllContext walking the last-seen links
backwards for the conversation around it.
Does dying clear a broken chain? No, and nothing short of a new key or a
new connection does. The chain decoder lives on ServerGamePacketListenerImpl, and a respawn
builds a new ServerPlayer while keeping the same listener —
PlayerList.respawn copies the connection across and ServerPlayer.restoreFrom
copies the chat session with it — so the broken link survives death and the other respawn that
replaces the player object, leaving the End. Within one connection only
ServerGamePacketListenerImpl.handleChatSessionUpdate, given a different key,
installs a fresh decoder.
And if somebody’s session key fails validation? Which side notices decides
the cost. On the server it closes the connection. On another client,
ClientPacketListener.initializeChatSession calls
PlayerInfo.clearChatSession, and that player’s lines are treated as unsigned — the
signature stripped by SignedMessageValidator.ACCEPT_UNSIGNED and the line
tagged not secure, or refused
by SignedMessageValidator.REJECT_ALL where secure profiles are enforced.
Where to look
Follow one message, in the order it travels. ClientPacketListener.sendChat
is where a line becomes a signed object, and
LastSeenMessagesTracker.generateAndApplyUpdate beside it is the window
being committed to. ServerGamePacketListenerImpl.handleChat is the
receiving end, and it is short: the two methods it calls,
ServerGamePacketListenerImpl.unpackAndApplyLastSeen and
ServerGamePacketListenerImpl.tryHandleChat, are the five Netty-thread
checks in the table, in order. Then PlayerList.broadcastChatMessage for the
per-recipient fan-out and ClientPacketListener.handlePlayerChat for the
far end doing it all again in reverse.
Two classes are the argument rather than the route.
LastSeenMessagesValidator is a short file of suspicion and is worth
reading in full, because every rejection in it is a connection close.
SignedMessageChain is the other, and the one line in
SignedMessageChain.Decoder.setChainBroken that drops the next link is what
chain broken means.
Three doors this page only points at:
PlayerChatMessage.updateSignature, the exact byte order the signature
covers; ChatTrustLevel.evaluate, the crude contains test behind the
tag; and ChatReportContextBuilder.collectAllContext, which walks the
last-seen links backwards and is the reason the window is signed at all.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
X · The client
Verified against Minecraft 26.3 · Part X · One thread, one loop, and everything else in the part answering the same question about itself: when in that loop does this happen?
A player already knows this part by its symptoms: the stutter where the world moves on without you, the block that appears and then disappears, the inventory that waits for the next tick to open. Every one of them is the same arrangement seen from a different angle. Everything in this part that changes the client’s world happens on one thread, and nothing in the client’s simulation is driven by a scheduler or a timer callback; despite the name printed in every stack trace there is no separate render thread, and the thread called Render thread is the same one that ticks the world, applies packets, handles your keyboard, decides what a screen looks like and asks the GPU to draw it. So the client has no way to do two of those at once, and no way to be interrupted into doing one of them late. Everything that looks like the client falling behind is one thread deciding what to spend a frame on.
The shape of the part
Part X is a hub and its spokes, and the spokes are cadences rather than stages: with two exceptions, noted below, nothing here hands off to anything. The client loop is the hub because it is the one page that says when anything on the client runs, and every other page in the part answers the same question about itself: when in that loop does this happen? The figure has seven spokes for twelve pages, because the four GUI pages answer it together and so do the two about sound.
flowchart LR
LOOP["1 · The client loop — the hub"]
LEVEL["2 · The client level"]
PRED["3 · Prediction and acknowledgement"]
INPUT["4 · Input and keybinds"]
OPT["5 · Options"]
GUI["6-9 · The GUI stack"]
SND["10-11 · Sound"]
DBG["12 · Debugging the running game"]
LOOP -- "per tick, light per frame" --> LEVEL
LOOP -- "per action, a window per try" --> PRED
LOOP -- "per SDL event" --> INPUT
LOOP -- "per save" --> OPT
LOOP -- "per frame" --> GUI
LOOP -- "per event, then off-thread" --> SND
LOOP -- "per tick, and a packet on change" --> DBG
The hub and its spokes, numbered to the watch order, a range being pages watched together and the hub drawn sideways because it is not a pipeline. The labels on the arrows are cadences, not steps: each spoke answers the hub’s question — when in the loop does this happen?
The one genuine pipeline inside the part is the GUI stack, and it is a pipeline whose stages interleave rather than queue: a screen records itself into a tree, and the text in it is measured and baked while it is being recorded, because the tree cannot place a line it has not measured. So the tree comes before the text here — you cannot follow the text pipeline until you know what it is recording into — with the HUD after them both as the other thing that records into the same tree. Outside that stack and one pair, nothing in the part depends on anything in the part except the hub: the client level and prediction share a ledger, and everything else answers the loop independently.
Before you start
Part IX, and not optionally — this part is the same wire watched from the receiving end. Three pages here begin at a packet that has already arrived, and the connection is what it took to get there.
Part I’s anatomy for the two-loops figure, which is the premise of the whole part: the server’s tick loop and the client’s frame loop are different clocks, and two of the pages here, the client level and prediction, open on one of them waiting on the other.
Authority from Part VI, because “what the client is allowed to decide” is the question this part splits in two: the client level answers what it may simulate, prediction and acknowledgement what it may show before it is told, and neither re-derives the five predicates.
Three smaller ones, each for one page. Containers and
menus
from Part VII before GUI and screens, which opens on a
menu and takes for granted that the chest you see is not the chest.
Part V before
prediction and acknowledgement: the ledger’s six
windows open around rather more than a block placed and a block broken, but
those two are the ones a viewer needs to have seen, and Part V’s landing
page already rules that its pages are watched first. And text
components from Part II before text and
fonts, which starts from “you have a Component”.
Watch in this order
- The client loop — the hub, and the one page every other page in the part leans on. How much simulated time a frame owes, what it spends it on, and what happens to the time it cannot afford. Watch this before anything else in Parts X and XI.
- The client level — the same
Levelclass the server runs, with its authority removed. A comparison: what the client really simulates, and what it only pretends to. - Prediction and acknowledgement — the block that
appears and then disappears. One ledger, one counter, and a receipt that
is not a verdict. Watch it straight after two: the ledger it turns on lives
on
ClientLeveland is reached through four of that class’s methods. - Input and keybinds — everything between the operating system and a key being down, the four places a press can be swallowed on the way, and a fifth before a click counts.
- Options — one flat file and nine fields the server ever hears about. A policy page: what saving does, and who is told.
- GUI and screens — what a screen is: the manager, the lifecycle, the widget family, and the four routes by which a screen comes to exist.
- The GUI render tree — what a screen records into. Nothing in the 2D UI draws anything; it all appends to a tree that infers its own layering from bounding boxes.
- Text and fonts — the one thing recorded into that
tree that is a pipeline of its own: six stages from a
Componentat one end to a quad with a glyph on it at the other. - The HUD — the other thing that records into that tree, and the part’s second policy page: what is drawn over the world, in what order, and under exactly which conditions.
- Sound: the engine — the page in the part with the most threads in it: five take part, a block placed near you crosses four of them on its way to an OpenAL source, and one hop to the sound thread it cannot skip.
- What makes a sound happen — the content model: three doors a sound comes through, only one of which names it.
- Debugging the running game — the closer, and the part’s one pattern lecture: one subscription mechanism, sixteen instances, all of them shipped and fifteen of them unreachable without a JVM flag.
Six to nine are the GUI stack and are watched together. Ten and eleven are the two halves of sound and can be watched in either order; the engine first is the easier way round.
Where the part stops
At one end, the profiler’s frame zone: the frame is one span inside a turn of the client loop, which picks up again after it to settle the pause, and everything inside that zone is Part XI’s — though a few of this part’s own cadences run inside it and stay here for what they decide, the per-frame light pass and the GUI record pass among them. What the server chose to send is Part IX’s.
At the other end, and much the larger boundary: this part explains what a
screen, a widget and a glyph are, not the hundred and thirty-odd screens the game ships. 27% of the part’s lines are named on no page in the book, and about four fifths of that is one more Screen or one more widget — the friends and social screens, the pack picker, the recipe book, a class per container. GUI and
screens covers them as one pattern with
four routes into it, on purpose. Some of the part’s packages hold other parts’ subjects too: the model tree under client/resources is Part XI’s, and client/player, client/multiplayer and the dialog screens are shared with Part VIII, Part IX and Part XIII. One subject belongs to nobody: player reporting, which this book skips; the atlas counts its chat-report package in no part, and its report screens here.
Reference this part uses
Diagram lanes for the abbreviations these pages’ figures use, and the threads, which is where the sound engine’s own thread sits among the rest of the game’s. HUD elements is the gate table the HUD is built on, in record order. Packets for everything arriving from Part IX, and the glossary for partial tick, prediction ledger and extract. Naming drift is the one to keep open beside this part in particular: eight of these twelve pages carry a for a 1.21-era reader box.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
The client loop
Verified against Minecraft 26.3 · Part X · one turn of
Minecraft.run: how much simulated time a frame owes, what it spends it on, and what happens to the time it cannot afford.
The client has one loop and no schedule. A tick is not a timer callback and not a thread — it is something the loop does on its way to a frame, as many times as the clock says it owes. The clock is asked once per iteration, it answers in whole ticks, and the loop then runs at most ten of them. A frame that earned fifteen runs ten and loses five — and the five are not deferred. The clock keeps its leftover as a fraction of a tick and hands out whole ticks as they come due; a tick that has been counted has left that leftover for good, so nothing will ever run the five, and the world you are standing in has skipped forward without simulating the gap. That the two programs keep different clocks at all is the two loops, and the contrast is sharpest here: the server drops ticks too, but only once it is more than the overload threshold plus twenty ticks behind, and it logs Can’t keep up! when it does (the server tick). The client does it on any frame that needs to, at a ceiling of ten, and says nothing.
The loop on this page runs on one thread, and it is the client’s in the four threads. Main.main renames the JVM’s main thread to "Render thread" and RenderSystem.initRenderThread claims it, so
Minecraft.gameThread, BlockableEventLoop.isSameThread and
RenderSystem.assertOnRenderThread all agree about that one thread. The name
is the trap. There is no separate render thread — no second thread drawing
while the first one ticks — and there never was one in this version. What the
stack traces call Render thread is the thread that runs the loop, ticks and draws, and it is
what every Render thread in this part’s cast tables means.
The cast
| class | what it decides | thread |
|---|---|---|
Minecraft | the loop itself, and — being a ReentrantBlockableEventLoop — the main thread’s task queue | Render thread |
DeltaTracker.Timer | how many whole ticks this frame owes, and what the leftover fraction is | Render thread |
PacketProcessor | where packets decoded on Netty threads wait to be applied | filled on Netty, drained here |
TickRateManager | the tick rate the Timer’s target comes from — a shared class the client holds one of, whose rate and freeze only the server sets | read here, decided there |
FramerateLimitTracker | what the frame cap actually is, which is not always the option | Render thread |
FramerateLimiter | the park that enforces it | Render thread |
Main | the process: the config, the shutdown hook, the thread’s name | JVM main = Render thread |
One turn of the loop
Minecraft.run spins until Minecraft.running goes false, and each
iteration is RenderSystem.pollEvents followed by Minecraft.runTick. The
figure is that iteration. It is drawn as a flowchart rather than a
conversation because the fact worth having is a decision — the clamp, and
what falls off the end of it.
flowchart TD
POLL["RenderSystem.pollEvents — SDL events, inline"]
subgraph RT["Minecraft.runTick"]
PRE["Pre render: Window.shouldClose, then a pending reload"]
ASK["DeltaTracker.Timer.advanceGameTime — whole ticks owed"]
DRAIN["PacketProcessor.processQueuedPackets, then BlockableEventLoop.runAllTasks"]
TEX["TextureManager.tick, once if a tick is owed and the world is not frozen"]
CLAMP{"more than ten ticks owed?"}
TICK["Minecraft.tick, up to ten times"]
PREFRAME["Render: SoundManager.updateSource, then MouseHandler.handleAccumulatedMovement"]
FRAME["Minecraft.renderFrame, then the limiter parks"]
POST["Post render: recompute Minecraft.pause, the timer's pause and freeze"]
PRE --> ASK --> DRAIN --> TEX --> CLAMP
CLAMP -- "no" --> TICK
CLAMP -- "yes: the excess already left the residual" --> TICK
TICK --> PREFRAME --> FRAME --> POST
end
POLL --> PRE
POST -- "Minecraft.run, next iteration" --> POLL
One iteration, with the boundary drawn: only RenderSystem.pollEvents is
outside Minecraft.runTick, which is why a key press lands in no profiler
zone. Both answers to the clamp reach the same node, which is the point: at most ten run either way, and what differs is what became of the rest.
Read it as owe, spend, draw, settle. The clock says how much simulated time has passed; the loop spends it on packets, tasks and up to ten ticks; the frame draws whatever the world looks like afterwards; and only then does the loop notice whether the game is now paused — which is why the first frame of a pause is drawn unpaused.
Two of the figure’s nodes need unpacking. The drain is two calls, not one — PacketProcessor.processQueuedPackets takes
the packets Netty decoded, BlockableEventLoop.runAllTasks takes everything
else the thread was handed — and the pair between the ticks and the frame is
SoundManager.updateSource, which sends the camera to the OpenAL listener on the sound thread, then MouseHandler.handleAccumulatedMovement, which turns the mouse delta into rotation. Both of the second pair run after the ticks and before the frame, so an iteration’s ticks run on the old rotation and its frame shows the new one.
Pre render, Render and Post render, which head three of the figure’s
nodes, are Window.setErrorSection calls — the crash report’s breadcrumb, so a
client that dies takes whichever of the three it was in to the report with it. What happens inside the frame is the
frame;
this page does not follow the loop inside the profiler’s frame zone, and every zone inside
it is that page’s. Note where the frame limiter sits: inside Minecraft.renderFrame,
after the present, with only the fpsUpdate zone after it, and before the
pause is recomputed.
The ten, and the arithmetic behind it
DeltaTracker.Timer.advanceGameTime takes the elapsed milliseconds, divides
by whatever DeltaTracker.Timer.targetMsptProvider returns for
DeltaTracker.Timer.msPerTick, adds the result to
DeltaTracker.Timer.deltaTickResidual, takes the whole part out and returns
it. The fraction that stays behind is the partial tick everything
interpolates against. The clamp then happens in the loop, not in the Timer —
so the ticks above ten are not deferred to the next frame, because they left
the residual when they were counted.
Ten ticks is the ceiling, the number Minecraft.MAX_TICKS_PER_UPDATE names. The clamp in Minecraft.runTick shows the number rather than the name because javac writes a static final int set to a literal in at every use site, so a decompile can never tell a documented constant from a dead one.
The divisor is not the client’s to choose. DeltaTracker.Timer gets its
target from DeltaTracker.Timer.targetMsptProvider, which is
Minecraft.getTickTargetMillis, which asks the level’s TickRateManager
for TickRateManager.millisecondsPerTick whenever it
TickRateManager.runsNormally, and never less than fifty milliseconds. /tick rate is a server command that changes the arithmetic inside the client’s frame loop — when it slows the world down; a faster rate leaves the client at twenty ticks a second. /tick freeze is
a different lever — under it the Timer’s divisor falls back to fifty milliseconds — and the loop reads it directly rather
than through the Timer: Minecraft.isLevelRunningNormally asks the level’s
TickRateManager again, and that is what stops TextureManager.tick — which
is why freezing the world freezes the water texture — and what gates
ClientLevel.animateTick and ParticleEngine.tick inside the tick. The
Timer is told the same answer at the end of the iteration, through
DeltaTracker.Timer.updateFrozenState, so that the partial tick it hands out
stops moving too.
Alongside the game clock the Timer runs a second, unpausable one.
DeltaTracker.Timer.advanceRealTime produces
DeltaTracker.getRealtimeDeltaTicks, which the menus’ panorama and the autosave indicator animate against, so they move when game time does not. The two constants DeltaTracker.ZERO and
DeltaTracker.ONE — two instances of the one nested DeltaTracker.DefaultValue
— exist so that code which needs a partial tick can be handed no
interpolation or complete interpolation without a branch.
What a tick is, in order
Minecraft.tick is one long method, its order is a dependency order, and the
column that matters is the third: most of it is inside a gate, and two of the things that are not are the reason the main menu has music.
| in order | what runs | what gates it |
|---|---|---|
| 1 | Minecraft.clientTickCount advances | nothing |
| 2 | TickRateManager.tick | a level, and not paused |
| 3 | the game mode | a level, and not paused |
| 4 | Minecraft.pick, at a partial tick of one | nothing |
| 5 | Tutorial.onLookAt, with what the pick found | nothing |
| 6 | Gui.tick — Minecraft.missTime pinned high while a screen is open | nothing |
| 7 | the keybind drain, Minecraft.handleKeybinds | neither a screen nor an overlay — and nothing about a level |
| 8 | GameRenderer.tick, ClientLevel.tickEntities, Level.tickBlockEntities, then LocalPlayer.sendChanges — the input and movement packets | a level, and not paused |
| 9 | the music and sound managers | nothing |
| 10 | the first-server toast, Tutorial.tick, then ClientLevel.tick alone inside a crash-report handler | a level, and not paused |
| 11 | ClientLevel.animateTick, ParticleEngine.tick | a level, not paused, and the tick rate manager running normally |
| 12 | ServerboundClientTickEndPacket | a level, a connection, and not paused |
| 13 | KeyboardHandler.tick, where the F3+C crash countdown lives | nothing |
Six of the thirteen rows are gated on nothing where they are called, and four of them are what a client with no world is still doing: it counts ticks, it runs the interface, it plays music, and it runs the F3+C crash countdown. The pick and the tutorial’s look are called too, and each returns at once without a level.
Everything that is the world is inside level != null && !pause. With no
level that middle collapses — but not into one branch. Two separate else
arms at two points in the method clear the spectated entity’s post-effect and
tick the pending connection, with row nine running between them.
Two of those rows are load-bearing elsewhere in the book. Row twelve is why
a client still in configuration sends no tick-end packet at all: it sits
inside the level block, and the server reads the packets it does get to decide
that a player who sent no movement this tick is standing still. And row four
is only half of Minecraft.pick, which runs once per tick and once per
frame — the tick’s call at a partial tick of one, the
frame’s at the real one, and it is the frame’s result the crosshair and the
block outline use. A frame that runs three ticks calls it four times; a frame
that runs none calls it once.
Where work leaves this thread, and where it comes back
Five ways off this thread, and one re-entry that is not a way off it.
- Packets decoded on Netty threads are parked by
PacketProcessor.scheduleIfPossibleand drained in the scheduledPacketProcessing zone — once per frame, not once per tick. That single fact is behind most of what looks like network jitter; the connection is the other side of it. - Tasks from other threads land in scheduledExecutables through
BlockableEventLoop.execute. From this thread the same call usually runs inline instead of queueing — but not while a task is already running, inline or queued, becauseReentrantBlockableEventLoop.scheduleExecutablesreturns true for the whole ofReentrantBlockableEventLoop.doRunTask, which is what stops a task from re-entering itself. SDL events, dispatched bySDLEventHandler.pollEventsinsideRenderSystem.pollEvents, are not inside one, so they execute before the tick that will observe them — see input and keybinds. - Section meshing goes to
Util.backgroundExecutorand is collected bySectionRenderDispatcher(Part XI). - Timers, of which the client has exactly two, and neither changes the game from its own thread.
PeriodicNotificationManagerandRemoteFriendListUpdateHandlereach own a scheduler, and each hops back here withBlockableEventLoop.executebefore touching the game (the friends list swaps its own fetched data in on its own thread) — which is why nothing in the client’s simulation is ever driven by a timer callback. - GPU work registered with
RenderSystem.queueFencedTaskis picked up byRenderSystem.executePendingTasks, which stops at the first unsignalled fence rather than waiting. It looks general and is not: the one thing in the tree that queues a fenced task is the OpenGL backend’s asynchronous texture readback.
The re-entry is BlockableEventLoop.managedBlock, which pumps that second
queue while the loop is blocked waiting for the integrated server or for a world’s resources to load — so work
runs on this thread at a moment when the thread is not running its loop at all.
The mechanism is the server
tick.
The profiler wraps all of it. Minecraft.constructProfiler picks one per iteration — InactiveProfiler, or the frame-profile ContinuousProfiler behind the F3 pie chart — combined with the MetricsRecorder’s profiler while it records and a SingleTickProfiler when a debug flag asks for one, and Minecraft.finishProfilers ends the single-tick one and posts the pie chart’s results. RenderSystem.pollEvents is
inside the profiler scope but outside Minecraft.runTick, so input polling
lands in no named zone and shows up on the pie chart as unspecified time.
That is not why
RenderSystem.isFrozenAtPollEvents exists, though: its one caller is
ClientCommonPacketListenerImpl.handleKeepAlive, which holds the keep-alive reply back while a poll has been stuck for more than 200 milliseconds and sends it from the connection’s first tick after the poll returns — so a game stuck in the poll by a window drag reaches the server as latency.
Pausing, which is a field the screen votes on
Minecraft.pauseIfInactive, called during the frame, opens the pause menu when no screen is up, the window has been unfocused for more than half a second and Options.pauseOnLostFocus is on — and the menu pauses the game only through its vote. Minecraft.pause — the field — is
recomputed at the very end of Minecraft.runTick as a conjunction of three:
singleplayer, and the GUI says we are pausing, and the world is not open to
LAN. Only the middle one is a question about the interface — Gui.isPausing
asks the current screen and overlay, and Screen.isPauseScreen defaults to
true, which is why the options screen stops a singleplayer world and a
chest does not (GUI and
screens owns the vote and the
override a container screen casts). On the rising edge the loop calls SoundManager.pauseAllExcept, sparing music and UI sounds, and every iteration it hands the state to DeltaTracker.Timer.updatePauseState.
The frame cap is usually the option, and sometimes is not
FramerateLimitTracker.getFramerateLimit returns the option unchanged
normally; caps it at thirty after a minute idle; replaces it with ten when
the window is iconified or after ten minutes idle; and replaces it with
sixty in a menu with no level — which can be more than the player
asked for. The iconified case is iconified, not unfocused — a different
state with a different consequence, one section above — except in exclusive
fullscreen, where an unfocused window counts as iconified. The two idle
cases apply only when Options.inactivityFpsLimit is set to the AFK
behaviour, and the iconified test wins over both.
FramerateLimiter.limitDisplayFPS is skipped entirely at or above 260 — the
option’s own maximum, i.e. “unlimited”; below it, it parks for most of the
remainder, correcting for how much the JDK’s park habitually overshoots, and
busy-spins the last fraction. The profiler notices too, though it does not
stop: FramerateLimitTracker.isHeavilyThrottled is the ContinuousProfiler’s
suppress warnings predicate, so a throttled client still measures itself but
stops complaining that its frames are slow.
The frame’s timing is three different measurements, two of them on the F3 screen, and it is worth knowing which is which. Minecraft.fps is a static field sampled once a
second, and is the one printed as fps. Minecraft.frameTimeNs is a CPU span
that stops at the blit, before the present and before the limiter, and is read
by nothing but telemetry — so it appears on no line of the overlay. The third
has no name of its own: the frame-time graph is fed wall-clock between
frames, measured after the limiter, so it includes the sleep — as the frame count does and Minecraft.frameTimeNs does not.
Starting, and the three ways of stopping
Main.main builds a GameConfig from the command line, installs a shutdown
hook, renames the thread, calls RenderSystem.initRenderThread and
constructs Minecraft; a SilentInitException out of that constructor exits
quietly rather than crashing. Minecraft.running is set true inside that
constructor, but five statements after the Options are read from disk. A
setting’s listener only fires while that flag is true, so every
OptionInstance.set performed while loading options.txt silently skips its
own — the loading
guard is that
rule and what it costs. Main.main then
calls Minecraft.exitWorldAndClose, and its last statement arms
ClientShutdownWatchdog.startShutdownWatchdog over what follows. That is the
second of two armings, not the only one: the window-close callback arms the same watchdog against the Render thread while the game is still running, so a close the window is asked for is watched from the moment of the request, where a Quit button’s is not watched until Main.main arms it after Minecraft.exitWorldAndClose returns. The two do not have the same powers, either.
Both start a daemon thread that sleeps fifteen seconds and, unless a later arming has moved the counter on by then, builds a crash report from the main
thread’s stack — but the close-callback arming stops there, at report only,
while the one after Minecraft.run has returned goes on to take the process
down — unless the close-callback watchdog has already fired, which leaves the counter where no later arming can start.
Stopping has three doors and one corridor. Minecraft.stop sets
Minecraft.running false, and is what Window.shouldClose triggers at the
top of Minecraft.runTick. Minecraft.emergencySaveAndCrash is where a
ReportedException or any other throwable from the loop body ends up, by way
of Minecraft.emergencySave, which releases the reserved memory block, halts
the integrated server and shows the saving screen. And an out-of-memory error
does not necessarily end anything: the first one makes Minecraft.run stop
advancing game time altogether — GUI only, no ticks, no packets, no world —
after an emergency save; a second one rethrows.
The corridor is Minecraft.exitWorldAndClose and then Minecraft.close,
which tears down in a fixed order — the time source first, outside the try,
then an offline presence to the friends service, the friends list, the timer
query, telemetry, compliancies, the atlas and font managers, the game renderer,
the shader manager, the level renderer, the sound manager, the three texture
managers, resources, the Tracy capture, the narrator, FreeType, the surface and
the renderer — and then, in a finally block, only the window, the graphics
backend’s library, SDL’s own shutdown and the executors. There is no Minecraft.destroy.
For a 1.21-era reader. Names to stop hunting for: Minecraft.getPartialTick, Minecraft.noRender, Minecraft.tell, Minecraft.destroy, Minecraft.screen and Minecraft.setScreen (both now on
Gui), Timer (nowDeltaTracker.Timer), GLFW and its callbacks (now SDL events, drained bySDLEventHandler.pollEvents), and initGameThread / isOnGameThread, which do not exist because the second thread they distinguished does not either.
Where to look
Minecraft.run and Minecraft.runTick — the loop is those two methods.
DeltaTracker.Timer.advanceGameTime for the tick arithmetic and
Minecraft.getTickTargetMillis for who sets its rate. Minecraft.tick for
the ordered contents of a tick. FramerateLimitTracker.getFramerateLimit for
the frame cap that is not the option. Main.main for how the process starts,
and Minecraft.close for the order in which it comes apart.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
The client level
Verified against Minecraft 26.3 · Part X · the same
Levelclass the server runs, with its authority removed: what the client really simulates, and what it only pretends to.
Place a repeater on the client and it is there — drawn, collidable, part of
the world. Ask the client whether that repeater has a tick scheduled and it
will tell you, confidently, that it does not. ClientLevel’s two scheduled-tick lists are the empty list BlackholeTickAccess hands out: it accepts a schedule, drops it, reports
no when asked whether a tick is pending, and counts zero. Shared block code
that consults the level therefore gets a wrong answer rather than an error,
and anything that reschedules itself looks inert until the server speaks.
That is the shape of the whole class. ClientLevel is not a passive receiver
and not an authority either: it simulates hard — every block entity with a client ticker ticks regardless of distance, every local block change that alters how light passes is relit locally, it keeps
its own clock and free-runs between corrections — while inheriting a set of
shared Level methods that have been quietly reduced to constants. Reading
ClientLevel is largely a matter of noticing which overrides are empty.
(Authority is a word with five predicates behind it, and
authority
is where they are set out; this page needs only the root, Entity.isLocalInstanceAuthoritative, which decides whose positions arrive from the server at all, and one fact about blocks: the server alone decides a block’s state.)
The cast
| class | what it decides | thread |
|---|---|---|
ClientLevel | what the client simulates, and what it answers when asked | Render thread |
ClientChunkCache | which chunks exist, in a fixed-size array a few chunks wider than the view diameter, indexed modulo its side | Render thread |
ClientLevel.ClientLevelData | the client’s own game time, difficulty, horizon height and void darkness | Render thread |
LevelLightEngine | the light the client computes for itself, unbudgeted | Render thread |
LevelExtractor | the main route from the level to the renderer — pushed and pulled | Render thread |
ClientPacketListener | the view radius and simulation distance the server announced | Render thread |
TransientEntitySectionManager | entity storage with no persistence, no chunk save, no index to disk | Render thread |
Entity | whether a position update is a snap or an interpolation | Render thread |
Where the two levels differ
The comparison is the page. Every row but the last is a method both sides
inherit from the shared hierarchy — Level itself, or one of the interfaces
above it — and that the two sides answer differently; the last row is not a
method but the storage behind one. Seven of the nine rows are the client hollowing something out, the storage among them. Two run the other way — Level.setBlocksDirty, empty on the server and real here, and Level.shouldTickDeath.
| shared method | on the server | on ClientLevel |
|---|---|---|
Level.shouldTickBlocksAt | ticket range | inherited — unconditionally true |
ScheduledTickAccess.scheduleTick | real LevelTicks | both lists are BlackholeTickAccess.emptyLevelList |
Level.explode | the real thing | empty override — particles arrive by packet |
LevelAccessor.gameEvent | vibrations, sculk | empty override |
LevelReader.getUncachedNoiseBiome | generates | returns plains |
LevelReader.hasChunk | asks the source | unconditionally true |
Level.setBlocksDirty | empty | real — and it notifies LevelExtractor, never LevelRenderer |
Level.shouldTickDeath | true | stricter — within the server’s simulation distance |
| entity storage | persistent: a disk store, known UUIDs, per-chunk load states | transient: the same lookup, none of the bookkeeping |
Three of those deserve their own sentence. LevelReader.hasChunk returning
true unconditionally means that particular question is useless on the client —
though Level.isLoaded still works, because it goes through the chunk source
instead. Level.explode doing nothing is why an explosion you can see is not
a simulation: one ClientboundExplodePacket carries the sound, the particle and the knockback, and the handler plays all of it, the sound when the packet’s flag asks. And Level.shouldTickDeath is the only row where the client
is the stricter of the two: it uses the server’s announced simulation
distance to decide whether a dying mob plays its death animation.
That last row is also one of only two things in the client that read the
server’s announced simulation distance: the death-animation test, and
LevelExtractor’s render-stats string. (The client’s own
Options.simulationDistance is a different number with its own readers.) The
level keeps its own copy in ClientLevel.serverSimulationDistance, but never
decides it: the value arrives from the server on
ClientPacketListener.serverSimulationDistance, beside
ClientPacketListener.serverChunkRadius — both seeded at login and updated by
their own packets — and is handed to each new ClientLevel at construction
and thereafter through ClientLevel.setServerSimulationDistance.
What it does simulate: the two cadences
Per client tick, from Minecraft.tick:
ClientLevel.tickEntities, then
Level.tickBlockEntities, then ClientLevel.tick — which does
Level.updateSkyBrightness unconditionally and then, only if the tick rate
manager is running normally, the world border, the clock, the weather
effects and the breaking-progress sweep. After that, unconditionally again:
the sky flash countdown, the End flash state, and the explosion tracker.
ClientLevel.animateTick and ParticleEngine.tick follow, both gated on the
game not being frozen.
So “ticks regardless of distance” is true of distance and false of /tick freeze: Level.shouldTickBlocksAt is unconditionally true
here, but Level.tickBlockEntities still checks the tick rate manager, and
the loop skips the whole tick while paused.
Per frame, and only per frame: ClientLevel.update, which calls
ClientLevel.pollLightUpdates and then runs the light engine. It is gated on
the game being loaded, the level existing, and the frame being one that
advances game time — so the blocking loops that draw a frame without ticking
do no lighting either.
The light budget is a slope that ends in a cliff, and it budgets the wrong half of the work on purpose. Below ClientLevel.LIGHT_UPDATE_QUEUE_SIZE_THRESHOLD
the frame runs a tenth of ClientLevel.lightUpdateQueue, floored at
ClientLevel.NORMAL_LIGHT_UPDATES_PER_FRAME; at the threshold or above it
runs the entire queue. Then LevelLightEngine.runLightUpdates drains the
engine’s own propagation queue completely, every frame, with no budget at
all. The budget controls how fast the client accepts the server’s light,
not how fast it computes its own — so a burst of chunks below a thousand queued updates is spread over frames, a tenth of the queue (at least ten) each frame, and only a queue of a thousand or more is taken in one long frame.
A chunk arrives
The grounding trace, and the one place the whole class is visible at once. The
shaded band is what the figure is for: everything in it is one turn of Minecraft.runTick, so the light that arrives at the foot is not a later frame’s work — while the queue ahead of it fits the frame’s share.
sequenceDiagram
participant CPL as ClientPacketListener
participant CCC as ClientChunkCache
participant CL as ClientLevel
participant LLE as LevelLightEngine
participant LX as LevelExtractor
rect rgba(0, 0, 0, 0.04)
Note over CPL,LX: one Minecraft.runTick
CPL->>CPL: handleLevelChunk<br/>WithLight, on the Render thread
CPL->>CCC: replaceWithPacketData — blocks now
CCC->>CL: unload, if the torus slot was taken
CCC->>CL: onChunkLoaded — tint caches, ticking
CPL->>CL: queueLightUpdate — light later
CL->>CL: tickEntities, then tickBlockEntities
CL->>CL: update, then pollLightUpdates
CPL->>LLE: the queued lambda — setLightEnabled, updateSectionStatus
CPL->>CL: then setSectionRangeDirty, a 3x3 of columns
CL->>LLE: runLightUpdates, unbudgeted
CCC->>LX: setSectionDirty, from onLightUpdate
end
One packet, one Minecraft.runTick, and the light arriving at the bottom of it while the light queue is short: the band is the whole span, so the gap between the blocks and the light is not a wait. Note that the last arrow leaves the chunk cache, not the level.
Four things about the shape. Blocks and light are separated in the
handler, so a chunk exists, ticks and can be walked on before it is lit.
The separation is not a wait while the light queue is short: the whole band is the one Minecraft.runTick that handled the packet, and above forty frames a second the frame usually owes no tick at all, so the light is applied with nothing having ticked in between. In a burst below a thousand queued updates the frame takes only its share, and the rest waits for later frames. It is also one arrival, not two: the lambda ClientLevel.queueLightUpdate files runs both
ClientPacketListener.applyLightData and
ClientPacketListener.enableChunkLight, which is why the listener’s lane
wakes up again at the foot of the figure. And the renderer is reached two
ways —
the level pushes (ClientLevel.sendBlockUpdated, ClientLevel.setBlocksDirty,
ClientLevel.setSectionRangeDirty), but the chunk cache and four of ClientPacketListener’s own handlers reach LevelExtractor directly (the biome resend marks whole sections dirty, a transient block is queued through it, and login and the game-test highlight reach through it for the debug renderers), and the extractor also pulls:
ClientLevel.entitiesForRendering, ClientLevel.destructionProgress and
ClientLevel.getGloballyRenderedBlockEntities are read each frame and are
mutated with no notification at all.
Unloading is the same trace backwards: ClientChunkCache.drop clears the slot, ClientLevel.unload clears the chunk’s block entities, switches its light off and stops ticking its entities, and the removal of its light data is queued behind the other light updates.
The chunk cache is a torus
ClientChunkCache is not a map. ClientChunkCache.Storage is a flat
AtomicReferenceArray whose side is the view diameter plus a margin, indexed by the chunk coordinates modulo that side — so moving the origin evicts the ring
behind you by overwriting it. The array is atomic, the two centre coordinates
are declared volatile, and so is the reference to the
ClientChunkCache.Storage itself,
because the packet handlers are not the only readers. The section-compile
workers are: a RenderSectionRegion resolves biome tint live rather than
from its snapshot, so RenderSectionRegion.getBlockTint goes through
ClientLevel.getBlockTint into the chunk cache from a background thread while
the Render thread is moving the origin. The ThreadLocal and the read-write
lock inside BlockTintCache are the same contract said out loud.
ClientChunkCache.calculateStorageRange makes the array a few rings wider
than the view distance, ClientChunkCache.Storage.inRange and
ClientChunkCache.Storage.getIndex decide where a chunk lands, and
ClientChunkCache.updateViewCenter moves the origin by assigning two
integers. Its verbs are ClientChunkCache.replaceWithPacketData,
ClientChunkCache.drop, ClientChunkCache.replaceBiomes,
ClientChunkCache.updateViewRadius and ClientChunkCache.onLightUpdate,
and it keeps four delta sets — ClientChunkCache.addedEmptySections,
ClientChunkCache.removedEmptySections, ClientChunkCache.addedLoadedChunks
and ClientChunkCache.removedLoadedChunks — double-buffered by
ClientChunkCache.flipUpdateTrackingSets so the renderer can ask what
changed since last frame.
Who interpolates, and who snaps
An entity position arriving from the server does not simply become the
entity’s position. Entity.moveOrInterpolateTo hands it as a target to the
entity’s InterpolationHandler (Entity.getInterpolation), and if the
handler declines, the position, yaw and pitch are assigned directly. The base
Entity.createInterpolationHandler returns InterpolationHandler.NO_OP, which
declines everything, so the default across the entity tree is to snap; seven classes override it, and six opt in (a minecart only outside the minecart-improvements experiment).
supplies an InterpolationHandler | snaps |
|---|---|
LivingEntity — so nearly every mob and every remote player | AbstractArrow and every other projectile but FishingHook |
Display | PrimedTnt |
ExperienceOrb | ItemEntity — a dropped item |
FishingHook | FallingBlockEntity |
AbstractBoat and AbstractMinecart | Shulker, whose override hands back the no-op |
| everything else that does not override |
That table is the reason a dropped item’s movement looks different from a
mob’s over the same connection: nothing is smoothing it. The handler itself
— its window (for a living entity, as many steps as its type’s update interval: three by default, two for a player, an allay or a mannequin; for the others a fixed three, or a display’s teleport duration), and the 64-block distance past which
ClientPacketListener does not hand it the move at all and snaps instead — belongs to movement and
collision; what this page
owns is who has one. Entity.getClientPositionAndRotation is what ServerboundMoveVehiclePacket and PositionMoveRotation both ask, and it answers with the interpolation’s target when there is one or the entity’s current position.
The clock and the weather run themselves, and neither interpolates
Two of the things in that tick are the client keeping numbers the server also keeps, and both are worth a sentence because both are the class’s character in miniature: it free-runs, and it is corrected rather than told.
The clock. ClientLevel.tickTime increments ClientLevel.ClientLevelData.gameTime every tick the tick rate manager runs normally and hands the
result to a ClientClockManager — owned by ClientPacketListener, reached
through ClientLevel.clockManager, and the thing anything asking the client what time it is asks. ClientPacketListener.handleSetTime is the only correction: it is the only caller of ClientLevel.setTimeFromServer, and it also hands the clocks their updates. So the
time in a screenshot is the client’s own count since the last correction, not
the server’s.
The weather. Weather on the client is presentation only:
ClientLevel.tickWeatherEffects spawns rain particles and picks rain sounds,
while the rain and thunder levels are ramped on the server by ±0.01 a tick
and broadcast on every tick they change — about a hundred packets across a
five-second transition. What the client does not do is interpolate within a
tick: Level.setRainLevel writes the old and new values to the same number, so
the partial tick buys nothing and the level steps twenty times a second rather
than smoothly.
Two more numbers in that tick are deliberately coarse in the same way. The
breaking-progress sweep over ClientLevel.destroyingBlocks and
ClientLevel.destructionProgress runs only on every twentieth tick, and drops a crack nobody has updated for twenty seconds, which is what clears one left by someone who disconnected mid-dig (an abandoned dig is cleared at once by the server’s -1); the progress itself is applied the moment its packet arrives. And
ClientLevel.animateTick samples 667 positions at radius sixteen and another
667 at radius thirty-two every tick regardless of the machine, with
ClientLevel.doAddParticle culling by distance afterwards and able to
downgrade the particle setting stochastically on top.
The one thing in the class that is not coarse is the sound of your own footsteps, which LocalPlayer.playSound plays straight through ClientLevel.playLocalSound while the server leaves you out of its broadcast, so what lags is what you hear of other people (who hears
it owns the exclusion rule and the sounds this level defers for distance).
What else it holds
ClientLevel.tickingEntities is an EntityTickList, fed by ClientLevel.EntityCallbacks, whose ticking pair ClientLevel.entityStorage calls as a chunk starts and stops ticking and as an entity arrives in, crosses into or out of, or leaves a ticking one. Three more fields are each the
whole of a mechanism another page owns:
ClientLevel.globallyRenderedBlockEntities is the set that draws from
anywhere, ClientLevel.blockStatePredictionHandler is the ledger prediction
and acknowledgement owns, and
ClientLevel.explosionTracker is a per-tick budget of at most 512 block
particles that empties itself every tick rather than deferring anything.
The four tint caches, and the soft biome edge
ClientLevel.tintCaches holds four BlockTintCaches — grass, foliage, dry
foliage, water — and each is what makes a biome colour boundary look softer
than the biome boundary is. ClientLevel.calculateBlockTint box-blurs the
per-block answer over the columns the biome blend radius option names and
caches that, over a lookup that is itself on the ragged side of the two biome
borders (biomes). The blur is the
client’s, entirely: nothing on the server knows the edge is soft. That is also why a chunk arriving invalidates all four of them at once, in the trace above.
The client runs a real light engine — block light always, sky light only
where the dimension has it — and every client-side Level.setBlock that passes
the shared LightEngine.hasDifferentLightProperties test the
server uses relights (lighting owns what that test asks; the
consequence here is that a purely cosmetic change to a stair or a slab
relights anyway). Its push list, on the other hand, is one entity wide: ClientLevel.getPushableEntities returns at most the local player.
And ClientLevel never notifies LevelRenderer. It has no reference to it,
and not one per-block or per-section dirty method is left on LevelRenderer —
they are all on LevelExtractor now. What LevelRenderer keeps is
whole-world invalidation, LevelRenderer.invalidateCompiledGeometry and its
neighbours, which the extractor calls.
For a 1.21-era reader. Gone: ClientLevel.levelRenderer, every dirty method on LevelRenderer (now on
LevelExtractor), and ClientLevel.getStarBrightness and ClientLevel.effects (both nowEnvironmentAttributelookups — see environment attributes and timelines).
Where to look
ClientLevel.tick and ClientLevel.update for the two cadences.
ClientChunkCache.Storage for the torus, and
ClientChunkCache.replaceWithPacketData for what a chunk packet does. ClientLevel.pollLightUpdates for the budget arithmetic.
ClientLevel.tickEntities for the entity walk and ClientLevel.EntityCallbacks
for what joins the ticking set. Entity.moveOrInterpolateTo for the
snap-or-smooth fork. Then read the empty overrides — ClientLevel.explode,
ClientLevel.gameEvent, ClientLevel.getBlockTicks — because they are the
page in miniature.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Prediction and acknowledgement
Verified against Minecraft 26.3 · Part X · a block placed against a wall the server will not allow: the client shows it, the server refuses it, and a numbered receipt decides when the lie ends.
The client cannot wait a round trip to show you the block you just placed, so
it places it locally and tells the server afterwards. Everybody who has
described this system has described it as a rollback: the client guesses, the
server judges, the ack says yes or no. That is not what happens.
ClientboundBlockChangedAckPacket carries no verdict at all — it is a receipt
for a number, sent (once the server considers the client loaded) for actions the server refused exactly as it is sent for actions it allowed, and sent carrying zero for an aborted dig or a changed dig direction. Nothing in the
protocol ever says no. What makes the system correct instead is an
ordering rule: any correction the server intends travels earlier in the stream
than the receipt for it, because the correction leaves from inside the handler or with the level’s own tick, while the receipt is only a number the connection flushes after both.
This page is the machinery under that rule: one ledger per level, one counter per connection, six windows in which a prediction can be opened, and four methods that between them decide whether the world moves. It is also the client’s second answer to the question authority asks — the client level answers what may I simulate, and this answers what may I show before I am told — and Part V’s block interaction and block breaking are where the two applications are watched.
The cast
| class | what it decides | thread |
|---|---|---|
MultiPlayerGameMode | when a prediction window opens, and what goes in it | Render thread |
BlockStatePredictionHandler | the ledger: what was there before, under which sequence number | Render thread |
ClientLevel | the four writes — the prediction, the absorbed correction, the trigger, the settle | Render thread |
PredictiveAction | a one-method interface: given the sequence, produce the packet | Render thread |
ServerGamePacketListenerImpl | one integer per connection, and when it is emitted | Server thread |
ServerPlayerGameMode | the authoritative version of the same action | Server thread |
Two state machines, running against each other
The whole mechanism is one small state machine on each side, and neither knows the other’s state. The client’s runs per position; the server’s runs per connection.
stateDiagram-v2
state "Client — one position in the ledger" as CLIENT {
[*] --> Absent
Absent --> Retained : ClientLevel.setBlock
Retained --> Retained : again, sequence refreshed
Retained --> Corrected : ClientLevel.setServerVerifiedBlockState
Retained --> [*] : BlockStatePredictionHandler.<br/>endPredictionsUpTo
Corrected --> [*] : the same call
}
state "Server — one integer per connection" as SERVER {
[*] --> Idle
Idle --> Raised : ServerGamePacketListenerImpl.<br/>ackBlockChangesUpTo
Raised --> Raised : another acked action, same tick
Raised --> Idle : flushed, head of the listener's tick
}
Two machines that never read each other, on different clocks: the client’s runs once per position, the server’s once per connection. Both exits to the end are the same call, which writes the entry’s state wherever it differs from the screen: the state from before the prediction if no update arrived, the server’s if one was absorbed.
Read the two columns as running at different rates. The client’s machine
advances several times per tick, once per position touched. The server’s
advances on every packet that reaches a
ServerGamePacketListenerImpl.ackBlockChangesUpTo call. There are three such
call sites: ServerboundUseItemOnPacket, ServerboundUseItemPacket, and the
four destroying actions inside ServerboundPlayerActionPacket’s handler.
ServerboundPlayerActionPacket carries nine actions in all, and the other
five — dropping one item, dropping a stack, releasing a use, swapping hands
and the stab — raise nothing.
The counter then empties once per connection tick, which is why five acked
actions in one tick produce one receipt carrying the highest number, and why
one ack can drive a dozen positions out of the ledger at once.
Note what the diagram does not contain: any transition on which the server says no. There is none. Both of the client’s exit transitions are driven by the same packet, and which of the two a position takes is decided entirely by whether a block update reached it first.
The four writes
Everything the ledger does happens through four methods on ClientLevel, and
the difference between them is the whole mechanism.
ClientLevel.setBlock — the ordinary write. While a prediction is open,
and only if the write succeeded, it calls
BlockStatePredictionHandler.retainKnownServerState with the state that was
there before. If the position already has an entry, only the sequence is
refreshed: the ledger keeps the first pre-change state it ever saw for
that position, and the player position recorded with it.
ClientLevel.setServerVerifiedBlockState — every inbound block update
goes through here. If the position is in the ledger it overwrites the entry
and the world is not touched, so the prediction stays on screen. If it is
not in the ledger — the common case, for blocks the player did not touch
— it writes the world immediately. The absorption is per position, not per
packet.
ClientLevel.handleBlockChangedAck — the trigger. The receipt’s way into the ledger: it hands the receipt’s number straight to
BlockStatePredictionHandler.endPredictionsUpTo, which removes every entry at
or below it and passes each one’s recorded state to the settle.
ClientLevel.syncBlockState — the settle. Applies the recorded state
only if it differs from what is there, with flags Block.UPDATE_NEIGHBORS
plus Block.UPDATE_CLIENTS plus Block.UPDATE_KNOWN_SHAPE (the whole set is
in block update flags). That third flag
suppresses the shape pass, and neighbour updates are inert on the client
anyway — so the restore is a bare state write plus a remesh. A prediction can touch more than the block you clicked: placing one half of a door
writes the other, and a shape update can walk outwards from the block that
changed. Call that spread the cascade. The cascade does not re-run on the
way back. Reconciliation is correct only because every position the cascade
touched got its own ledger entry on the way out.
A placement the server refuses
The state diagram says what the states are; this says what order the packets arrive in, which is the part correctness rests on.
sequenceDiagram
participant MPGM as MultiPlayer<br/>GameMode
participant BSPH as BlockState<br/>PredictionHandler
participant CL as ClientLevel
participant SGPL as ServerGamePacket<br/>ListenerImpl
participant SPGM as ServerPlayer<br/>GameMode
MPGM->>BSPH: startPredicting — currentSequenceNr becomes n
MPGM->>CL: setBlock, from performUseItemOn through ItemStack.useOn
CL->>BSPH: retainKnownServerState — the truth, filed under n
MPGM->>SGPL: ServerboundUseItemOnPacket(hand, hit, n)
MPGM->>BSPH: close — the window shuts and the block is on screen
SGPL->>SGPL: hasClientLoaded? then ackBlockChangesUpTo — first
SGPL->>SPGM: useItemOn — the place fails canPlace, so nothing changes
SGPL->>CL: two ClientboundBlockUpdatePackets, whatever the outcome
CL->>BSPH: updateKnownServerState — the entry is overwritten, the world is not
Note over SGPL,SPGM: later in the same server tick, the listener's tick opens by flushing it
SGPL->>CL: Clientbound<br/>BlockChangedAckPacket, carrying n
CL->>BSPH: endPredictionsUpTo(n)
BSPH->>CL: syncBlockState — air goes back
CL->>CL: Entity.absSnapTo — only if the restored block now intersects you
The two ClientboundBlockUpdatePackets leave from inside the handler; the ack is only a field the listener’s tick flushes, later in the same server tick. That is the ordering rule for a refusal, visible here as the distance between the block-update arrow and the note.
The ack is recorded before the action is attempted, so by the time the
refusal happens the receipt is already promised. The correction is not the ordinary chunk broadcast but a targeted pair of resends the handler makes for every click that passes the build-height test, whether or not the action was then attempted. It skips them only when an earlier gate turns the packet away — an item a feature flag disables, a target out of reach, a hit point more than a block from the target’s centre, a target past the build limit — and then the ledger’s own record undoes the prediction. Whenever a correction is sent it is ahead of the receipt: the resends go out inside the handler, an accepted change’s broadcast with the level’s tick, and the ack is only a field assignment that ServerGamePacketListenerImpl.tick flushes after both. The settle is what
finally moves the world, and it is a no-op when the absorbed state is already
what is on screen. And the snap only happens when the restored block turns
out to be inside the player.
Because the ack is buffered rather than sent, where a handler records it
does not affect stream order — ServerGamePacketListenerImpl.handleUseItemOn
and ServerGamePacketListenerImpl.handleUseItem record it as the first statement inside the
ServerGamePacketListenerImpl.hasClientLoaded test, ServerGamePacketListenerImpl.handlePlayerAction after running the
break action, and in both cases the correction still reaches the client first.
The six windows
A window is a few microseconds long and entirely synchronous: on the Render thread, inside one call from Minecraft.tick. The counter is raised, the local effect runs, the packet is built with the new sequence and sent, and the window closes — in that order in five of the six, MultiPlayerGameMode.useItem building its packet before its effect — so the packet is always constructed while the ledger is still recording. MultiPlayerGameMode.startPrediction is the private method all six go
through, and it is not the same thing as
BlockStatePredictionHandler.startPredicting, which is what it calls: the
first is the window, the second is the pre-increment of BlockStatePredictionHandler.currentSequenceNr that gives the window its number, and the switch that sets ClientLevel.setBlock recording. ClientLevel.getBlockStatePredictionHandler is package-private, so
nothing outside client/multiplayer can reach the ledger at all.
| where | what it predicts locally | what it sends |
|---|---|---|
MultiPlayerGameMode.startDestroyBlock, creative | the block removed at once | ServerboundPlayerActionPacket START |
MultiPlayerGameMode.startDestroyBlock, survival | BlockBehaviour.attack, then removal if the block breaks instantly | START |
MultiPlayerGameMode.continueDestroyBlock, creative | removal, every sixth tick | START |
MultiPlayerGameMode.continueDestroyBlock, at full progress | removal | STOP |
MultiPlayerGameMode.useItemOn | MultiPlayerGameMode.performUseItemOn — the block’s hook, the empty-hand hook, ItemStack.useOn | ServerboundUseItemOnPacket |
MultiPlayerGameMode.useItem | ItemStack.use, including a transformed held item | ServerboundUseItemPacket |
So the ledger covers rather more than “blocks the player placed or broke”: it
covers every ClientLevel.setBlock performed inside one of those windows.
That includes the second half of a door, every position a shape cascade revisits, a door a right-click opens or shuts, and — the one attack hook that does it — RedStoneOreBlock.attack, whose
lighting change is not side-gated and therefore files a ledger entry when you
merely left-click redstone ore.
What the ledger does not cover
Half the value of this page is the list of things people assume it handles.
MultiPlayerGameMode’s non-predicting verbs are the giveaway:
MultiPlayerGameMode.attack, MultiPlayerGameMode.interact,
MultiPlayerGameMode.stopDestroyBlock,
MultiPlayerGameMode.releaseUsingItem,
MultiPlayerGameMode.piercingAttack and every container verb open no window
at all.
- Breaking progress.
MultiPlayerGameMode.destroyProgressand its companions are a plain parallel clock with no sequence and no reconciliation; the crack overlay is written straight into the client level. Of the progress clock itself nothing is predicted — only the removals at either end of it are. - Item use.
MultiPlayerGameMode.useItemopens a window, but a consumed item, a started use and a cooldown are not block states and the ack does nothing for them. They are corrected by other means: the living-entity flags in synched data,ClientboundCooldownPacket, and the menu resend the server performs when the stack changed. - Dropping.
MultiPlayerGameMode.dropItempredicts the removal from the selected slot and sends its action packet with sequence zero — a local mutation with no rollback path at all. - Movement. Rubber-banding is a different mechanism entirely: an
id-matched teleport handshake with no sequence and no ledger, described in
input to movement. The two systems touch
at one point in each direction —
ClientPacketListener.handleMovePlayercallsBlockStatePredictionHandler.onTeleport, so a teleport disarms the ledger’s position snap until a receipt passes the teleport’s number, and the settle callsEntity.absSnapToon theLocalPlayerwhen the restored block is inside you.
What one ack does, and the numbers that are not sequences
One receipt is not one action undone. Every entry at or below the acknowledged
sequence is removed and handed to the settle, and there the paths part: write
nothing, because the recorded state is already on screen (the correct
prediction); write the state; or write the state and snap the player. A single
ack can produce all three across the map in one pass — which is what the
player-position recorded in each entry is for, and the only thing that reads
it. BlockStatePredictionHandler.lastTeleportSequence is the exception that makes the snap coarse: it is compared against the acknowledged sequence rather than each entry’s, and is never reset. While the receipt is at or below the teleport’s number every snap in the batch is suppressed; once a receipt passes it, every snap is allowed, those of entries filed before the teleport included.
Zero is not a sequence. BlockStatePredictionHandler.startPredicting
pre-increments, so the first real sequence is one and no genuine prediction is
ever numbered zero. The three-argument ServerboundPlayerActionPacket
constructor defaults the sequence to zero and aborting a dig and changing its direction use it, so the
server dutifully sends a ClientboundBlockChangedAckPacket carrying zero — and
it settles nothing.
Nothing expires. There is no timeout and no cap on the ledger: nothing
clears it except a settle, and while the server considers the client not yet
loaded, sequenced packets are dropped before the ack is recorded. So a wrong guess made then stands on screen until a later receipt settles it with everything below it. The
only reset is a new ClientLevel.
And the two directions do not use the same flags. The predicted removal in
MultiPlayerGameMode.destroyBlock — the write inside the four breaking rows of the window table — goes out with Block.UPDATE_IMMEDIATE in the mix; the restore
comes back with Block.UPDATE_KNOWN_SHAPE. One cascades, the other
deliberately does not, which is the four writes’ asymmetry stated in flags.
Questions players ask
Why does the block come back and then vanish again? Because the two
progress clocks disagreed and the ack does not care — block
breaking
follows both clocks. What is this page’s is the consequence: the action is
acknowledged whatever the server decided, so the entry settles, the stone
comes back, and it vanishes again when the server’s own delayed destroy
finishes. Releasing the mouse does not do this — that is
MultiPlayerGameMode.stopDestroyBlock, which opens no window at all.
Why can I not right-click while mining? Because Minecraft.startUseItem
is gated on MultiPlayerGameMode.isDestroying, one of the click gates block
interaction
lists. A spectator is stopped in the other direction, and the ledger treats
the two halves of that asymmetrically: MultiPlayerGameMode.useItem returns
early, before the window opens, while MultiPlayerGameMode.useItemOn returns
inside it — so the sequence is burned and the packet is sent anyway.
Where to look
MultiPlayerGameMode.startPrediction — the whole client side is that one
private method and its six call sites. BlockStatePredictionHandler end to end; it is short and every line of it matters, including
BlockStatePredictionHandler.currentSequence,
BlockStatePredictionHandler.isPredicting and
BlockStatePredictionHandler.close. ClientLevel.setBlock,
ClientLevel.setServerVerifiedBlockState, ClientLevel.handleBlockChangedAck
and ClientLevel.syncBlockState for the four writes, in that order. Then the
server’s half, which is smaller than the client’s:
ServerGamePacketListenerImpl.ackBlockChangesUpTo — a method and, confusingly,
a field of the same name, the field holding minus one when there is nothing to
send — and the first statement of ServerGamePacketListenerImpl.tick, which is
where it goes out.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Input and keybinds
Verified against Minecraft 26.3 · Part X · holding sneak: an SDL event, four chances to be swallowed, and a key that stays down while you are not touching it.
Turn on toggle sneak and hold the key. ToggleKeyMapping.setDown sees the
press, flips the mapping to down, and then swallows the release entirely —
so as far as the rest of the game is concerned you are still holding a key
you let go of. Open your inventory and the mapping is released along with
every other one; close it and the mapping comes back on, because the toggle
remembered it was released by a screen rather than by you. Neither the flip nor the swallowed release involves the tick. The press and the release have both already happened by
the time the tick that observes them runs, and the tick’s part is only to read
what the mapping now says.
What the movement keys mean once they are down belongs to input to movement; this page stops at the mapping.
A key press is not queued
SDL events are not queued by the game. SDLEventHandler.pollEvents
drains them from SDL’s own queue inside RenderSystem.pollEvents, which
Minecraft.run calls immediately before Minecraft.runTick; SDLEventHandler
hands the input events to their handlers through BlockableEventLoop.execute,
but on the Render thread that call runs the task rather than queueing it — the
qualification the client
loop
puts on the same method. Any description of Minecraft input that says a key
press is “queued onto the client thread” is describing a different game.
The cast
| class | what it decides | thread |
|---|---|---|
KeyboardHandler | the key, character and pre-edit handlers, and four of the five gates a press runs | Render thread |
MouseHandler | motion accumulation, the sensitivity curve, and who is allowed to turn the player | Render thread |
KeyMapping | whether a mapping is down, and how many clicks are owed | Render thread |
ToggleKeyMapping | the four mappings that can behave as toggles: sneak, sprint, use, attack | Render thread |
InputConstants | the key universe, and the string a binding is saved as | Render thread |
InputQuirks | five platform constants that visibly change behaviour | Render thread |
KeyboardInput | which of the seven movement mappings are down, once per tick | Render thread |
Gui | the housekeeping at both ends of a screen’s life | Render thread |
Holding sneak
sequenceDiagram
participant KH as KeyboardHandler
participant MC as Minecraft
participant Screen as Screen
participant KM as KeyMapping
participant KI as KeyboardInput
rect rgba(0, 0, 0, 0.04)
Note over KH,KI: inside RenderSystem.pollEvents, on this thread
KH->>KH: keyPress — the SDL key event, run inline, never queued
KH->>MC: handleGlobalKeyPress — fullscreen, screenshot, friends
KH->>Screen: keyPressed — a screen that consumes it ends the story
KH->>KH: Options.<br/>keyDebugModifier down? then handleDebugKeys
KH->>KM: set — only with no screen open
KM->>KM: ToggleKeyMapping.<br/>setDown — flips, and will ignore the release
KH->>KM: click — counted, though nothing drains sneak's
end
rect rgba(0, 0, 0, 0.04)
Note over KH,KI: the next client tick
KI->>KI: tick, from LocalPlayer.aiStep — isDown on seven mappings
KI->>KI: an Input record, then a normalised move vector
end
Four gates, all in the top band; a drained press meets a fifth, the no-screen-no-overlay test Minecraft.tick makes before it drains the clicks, which sneak, read as held, never reaches. Everything in that band is one SDL event, the lower band is the next client tick, and nothing between them asked for the key.
A key press has five chances to be swallowed before it counts — the global-key check, an open screen, the debug modifier, the no-screen gate on recording, and the no-screen-no-overlay gate on draining, which a mapping read only as held, like sneak, never meets. And the two ends of a screen’s life are where the input system does its housekeeping: opening a screen releases every mapping, and closing one restores those toggles that asked to be restored — with default bindings, sneak and sprint, and only when there is a level.
Two ways gameplay reads a mapping, and they behave differently
KeyMapping.isDown is a boolean the movement code samples. KeyboardInput.tick
reads seven of them — forward, back, left, right, jump, sneak, sprint — once
per client tick, packs them into an Input record and derives a normalised
move vector. Nothing is consumed; a key held for ten ticks reads down ten
times.
KeyMapping.consumeClick is a drain, not an edge. It decrements a
counter that KeyMapping.click incremented, which means presses that
happened faster than the tick rate are not lost — and that binding two
mappings to one key code fires both, because KeyMapping.click increments
every mapping registered under that key. Minecraft.handleKeybinds is the
drain, and
it runs from Minecraft.tick only when there is neither a screen nor an
overlay.
KeyMapping.matches and KeyMapping.matchesMouse are the third way, used
where no counter is wanted: they test an event against the binding directly,
which is what screens do, and what KeyboardHandler.handleDebugKeys does
twenty times over as it works through the F3 combinations one test at a
time. KeyMapping.same,
KeyMapping.isDefault and
KeyMapping.isUnbound are what the binding screen asks.
The bulk operations, and their single callers
Options.keyMappings is the array every mapping is reached through — the
third of the three shapes options
lists. Beside it
KeyMapping keeps two static registries of every mapping ever constructed —
KeyMapping.ALL by name, KeyMapping.MAP by key — which is how a key code is turned back into the
mappings that want it. Five static operations walk them. Four of the five are
called from exactly one place each, and those four are the interesting ones —
the fifth, KeyMapping.resetMapping, rebuilds the key index from every mapping and has two callers, the binding screen and Options.load.
| operation | called from | why |
|---|---|---|
KeyMapping.releaseAll | Gui.setScreen (GUI and screens) | a screen may swallow a release, and a stuck-held mapping is worse than a lost press |
KeyMapping.restoreToggleStatesOnScreenClosed | Gui.setScreen | put back the toggles that the release above turned off |
KeyMapping.resetToggleKeys | LocalPlayer.respawn | you should not wake up sneaking |
KeyMapping.setAll | MouseHandler.grabMouse | asks SDL which keys are physically down and sets each willing mapping to match — only where InputQuirks.RESTORE_KEY_STATE_AFTER_MOUSE_GRAB is set |
The asymmetry between a swallowed press and a swallowed release is worth stating plainly, because it is the reason the first two rows exist. A press a screen swallows is harmless: the mapping was never set down. A release a screen swallows would leave the mapping down with nothing to clear it, which the first row prevents for every mapping set down before the screen opened.
The mouse: accumulate, apply, discard
MouseHandler.onMove, MouseHandler.onButton, MouseHandler.onScroll and
MouseHandler.onDrop are the handlers; MouseHandler.accumulatedDX and
MouseHandler.accumulatedDY are the pending motion; and
MouseHandler.handleAccumulatedMovement applies it — from Minecraft.runTick,
between the sound update and the frame, once per frame rather than once per
tick. So a look is applied at frame rate and a step is applied at tick rate.
Accumulated motion goes to whatever is in front of it and is then cleared
unconditionally. With a screen open the pointer goes to the screen’s move handler and the delta to its drag handler, and neither to the player — not because the two are exclusive in
MouseHandler, which tests them separately, but because opening a screen
released the mouse. With the window unfocused nothing
accumulates in the first place; and the reset at the end runs either way, so
motion is never banked.
MouseHandler.turnPlayer holds the sensitivity curve, and it has an
arithmetic surprise in it. The curve is a cube of the slider, and the
ordinary and smooth-camera paths multiply the result by eight while the
scoped path does not — so aiming a spyglass is exactly eight times slower,
by construction. The scoped path additionally requires the smooth camera to
be off, the camera to be first-person, and the player to be scoping.
Minimum sensitivity is not zero either: the cubed term is taken of the slider
scaled and offset, so the slowest setting still turns.
MouseHandler.grabMouse and MouseHandler.releaseMouse are two directions
of one edge with Gui.setScreen, guarded so the two cannot recurse —
though only MouseHandler.releaseMouse has the single caller;
MouseHandler.grabMouse has five, and on its way it also sets the current
screen to none. MouseHandler.isMouseGrabbed is the state and
MouseHandler.setIgnoreFirstMove suppresses the jump after a resize. A
control-click is a right click only on macOS and only with
Options.ctrlClickEmulatesRightClick on, which it is not by default:
MacosUtil.setCtrlClickEmulatesRightClick hands the setting to SDL as a hint,
and MouseHandler rewrites no click itself. Double-click is a threshold plus
two identities: the two clicks must be within a quarter of a second, on the
same button, and on the same screen instance.
The two debug-key families, and which one is bindable
Debug shortcuts look like one family and are two, which is why some of them
can be rebound and some cannot. Options.debugKeys holds twenty-one
ordinary KeyMappings, every one of them rebindable and every one of them
listed by the binding screen; KeyboardHandler.handleDebugKeys tests twenty
of them, one KeyMapping.matches apiece, and KeyboardHandler.keyPress tests
the twenty-first, the crash key, with the overlay and modifier keys. The
second family is not mappings at all: a raw switch on key codes in
KeyboardHandler.handleChunkDebugKeys, gated on the game’s debug flag and
bindable to nothing. Two of the bindable twenty-one, hitboxes and chunk borders, switch F3 entries that print no line and exist only to carry a flag something else reads — which is the seam between this page and the
HUD, whose F3 entry registry
is what those flags feed.
New categories are a third thing again. KeyMapping.Category is a registrable
record rather than an enum, so a mod can add one; registering a duplicate id
throws. Ordering is plain registration order into one list, and the eight
built-ins come first only because the record’s own static initialiser
registers them first.
What a key press sends, and from where
KeyboardHandler sends packets for two keys itself: ServerboundChangeGameModePacket for F3+N and, through DebugQueryHandler, a block-entity or entity tag query for the copy-recreate key. Every other press made outside a screen is drained by Minecraft.handleKeybinds, which sends the swap-offhand action itself and hands the others to the verbs that own them: an attack’s ServerboundPunchPacket, a use, a pick, a drop and a mount’s inventory each go out inside that same call, and a hotbar key’s slot goes out on the next tick from MultiPlayerGameMode.tick. A press a screen consumes sends what that screen sends. The movement keys reach the server only from the tick, as the input, movement, player-command and abilities packets LocalPlayer sends there. None of those verbs is this page’s.
Two smaller types sit beside the two handlers. ScrollWheelHandler is the wheel’s accumulator, one of which MouseHandler keeps, and InputType, which belongs to neither, is the
four-valued “what did the player last use” that decides initial keyboard
focus, narration timing and whether a focused widget shows its tooltip.
Questions players ask
I bound two things to one key and both happen. They will. Conflict
detection lives only in the binding screen — ControlsScreen over
KeyBindsScreen over the KeyBindsList that draws one row per mapping — and
is purely cosmetic: nothing in the input path resolves a collision. It also refuses to flag one when
both mappings are still at their defaults, which quietly exempts the pairs
the game itself ships colliding.
Why does F3 toggle on release? F3 is a key binding, and the debug modifier and the overlay toggle are the same key by default — so the overlay can only fire on the release, and only when no combination was used in between. Rebind either and that behaviour disappears.
For a 1.21-era reader. Input events are records now. The
(key, scancode, modifiers, action)integer tuple is gone from every screen signature, replaced byKeyEvent,MouseButtonEvent,CharacterEventandPreeditEventinclient/input, with shared helpers onInputWithModifiers— so a widget asks an event whether it is a confirmation or a paste rather than decoding modifiers itself. Also gone: categories as translation-key strings, and MouseHandler.lastMouseEventTime.
Where to look
KeyboardHandler.keyPress — four of the five gates are one method, read top to bottom. Then ToggleKeyMapping end to end, which explains
this page’s opening paragraph; Minecraft.handleKeybinds for the drain;
KeyboardInput.tick for the other way a mapping is read; Gui.setScreen for
the housekeeping at both ends of a screen; and MouseHandler.turnPlayer for
the sensitivity curve and its three gates.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Options
Verified against Minecraft 26.3 · Part X · the render-distance slider: a value that takes effect on a delay, rebuilds the world on a later frame, and is sent to the server only when the options are next saved, usually as a screen closes — with no reply.
There is no settings-changed hook in the Minecraft client. Saving is the
event system. The server learns your new view distance because something
called Options.save, and Options.save is the only caller of
Options.broadcastOptions — which sounds like a tight, tidy rule until you
notice that every cycle-option button calls Options.save on click. Change
chat visibility and your ClientInformation is on the wire before you have
left the screen; drag the render-distance slider and nothing is sent until the next save, which is usually the screen closing.
This page is the policy: what a setting is, when a change takes effect, who
finds out, and what the server does with the nine fields it is told. The
bindings that also live on Options are input and
keybinds.
The cast
| class | what it decides | thread |
|---|---|---|
Options | the file, the fields, and when to save | Render thread |
OptionInstance | one setting: value, codec, caption, listener, and the widget | Render thread |
OptionInstance.ValueSet | the legal values — and, by its subtype, whether the widget saves on click | Render thread |
ClientInformation | the nine-field record the server is told, kept in server/level | crosses the wire |
LevelExtractor | notices, next frame, that the effective render distance changed | Render thread |
ChunkMap | clamps the request and re-tracks, silently | Server thread |
IntegratedServer | the singleplayer back door: reads the sliders directly, every unpaused server tick | Server thread |
What happens when a setting changes
Four decisions stand between a widget and the wire, and none of them is “run the listener and be done”.
flowchart TD
CHANGE["a widget changes a value"]
KIND{"slider or cycle?"}
IMMED{"applies immediately?"}
ARM["arm 600 ms, and let the widget's extract pass notice it expire"]
SET["OptionInstance.set"]
RUNNING{"Minecraft.running?"}
SILENT["assign the field, no equality test, no listener — loading"]
LISTEN["the instance's own listener runs"]
CHANGE --> KIND
KIND -- "slider" --> IMMED
IMMED -- "yes, as it moves" --> SET
IMMED -- "no" --> ARM --> SET
KIND -- "cycle, on click" --> SET
SET --> RUNNING
RUNNING -- "no" --> SILENT
RUNNING -- "yes" --> LISTEN
Everything above OptionInstance.set is the widget’s business and everything
below it is the value’s. Note the Minecraft.running gate: the same method
that changes a setting is the one that loads the file, and on the loading path
no listener runs at all.
Saving is the second half, and it is where the two families stop behaving alike.
flowchart TD
CYCLE["a cycle button's click"]
SLIDER["a slider's value, set or armed"]
CSAVE["Options.save, from the button's own handler"]
CLOSE["OptionsSubScreen.onClose — apply anything still armed"]
REMOVED["OptionsSubScreen.removed — Options.save"]
WRITE["options.txt written"]
BCAST["Options.broadcastOptions — build a ClientInformation"]
SAME{"identical to the last one sent?"}
SEND["ServerboundClientInformationPacket"]
NOTHING["nothing is sent"]
CYCLE --> CSAVE --> WRITE
SLIDER --> CLOSE --> REMOVED --> WRITE
WRITE --> BCAST --> SAME
SAME -- "yes" --> NOTHING
SAME -- "no" --> SEND
Two entrances, one exit: a cycle saves on the click, so the packet is built before you have stopped looking at the button, and a slider waits for the next save, usually the screen coming down. The gate at the bottom is
ClientPacketListener.broadcastClientInformation, which compares the new
record with the last one sent and does nothing if they match.
The asymmetry is not a policy anybody wrote down. It comes from the value
set’s subtype and from nothing else:
OptionInstance.CycleableValueSet.createButton puts Options.save straight
into the button’s click handler, and OptionInstance.SliderableValueSet has
no such call anywhere, so a slider’s value reaches the field as it moves, or 600 ms after it stops, and then waits for the next save, usually the screen coming down.
OptionsSubScreen.onClose applies anything still in an armed timer first, so
leaving fast does not lose your drag.
The last gate is worth naming: ClientPacketListener.broadcastClientInformation holds the last ClientInformation it sent and compares. Save a screen without changing one of the nine fields and no packet leaves at all — except the first save of each play phase, whose new listener has nothing yet to compare against.
The three ways a setting is stored
OptionInstance is the modern shape — a value, a codec, an initial value, a
caption, a listener, and an OptionInstance.ValueSet that decides both the
legal values and the widget. The concrete sets are
OptionInstance.IntRange, OptionInstance.ClampingLazyMaxIntRange,
OptionInstance.UnitDouble, OptionInstance.Enum,
OptionInstance.LazyEnum, OptionInstance.AltEnum and
OptionInstance.SliderableEnum.
Plain fields are the older shape, read and written by name in
Options.processOptions: the language code, the resource-pack lists, Options.tutorialStep, Options.advancedItemTooltips, Options.joinedFirstServer, Options.startedCleanly and ten others. They have no listener and no widget machinery at all — and Options.smoothCamera, a plain field a keybind toggles, is not in the file.
The key-mapping array is the third, and is the input page’s subject.
Options.processOptions is worth naming twice: it is the file format and the
field list in one method, which is why it is the first place to look for
anything about options.txt. Options.dumpOptionsForReport and
Options.processDumpedOptions are the smaller subset that goes into the
profiling report.
The delay, and why the world rebuilds anyway
The render-distance slider’s listener does exactly one thing: it marks the graphics preset custom. It does not invalidate a single chunk. It is also one of only three options in the game that defer their value at all — with simulation distance and biome blend, it is built to not apply immediately, which is what arms the 600 ms; every other slider applies as it moves.
The world rebuilds because LevelExtractor notices, on the next frame,
that Options.getEffectiveRenderDistance differs from the last value it saw,
and calls its own full invalidation — tint caches cleared, the tracker
rebuilt, all geometry dirty. The setting and the consequence are joined by a
poll, not by a callback.
Sixteen other settings carry a listener that touches the graphics preset, and
they split cleanly. Seven of them also reach straight into the level
extractor and invalidate something: cloud range, cutout leaves, improved
transparency, ambient occlusion, anisotropic filtering, texture filtering and
biome blend radius. Those seven really are the callback the render-distance
slider is not. The other nine do exactly what render distance’s does and
nothing else: flip Options.graphicsPreset back to custom through
Options.setGraphicsPresetToCustom. That is why “Custom” appears without
anyone selecting it — a preset writes a batch of settings at once, and every
one of the sixteen, set by hand, undoes the preset’s name — though not when the preset writes its own batch, which Options.applyGraphicsPreset does with a flag raised that Options.setGraphicsPresetToCustom checks first.
Elsewhere in the file the immediate listeners are real enough, and the interesting ones are far more interesting than their options. GUI scale resizes whichever screen is open. Vsync invalidates the surface configuration, which is what the window is drawn into. Fullscreen toggles the window and then writes the option back from what the window actually did. The unicode-font toggle throws away every glyph atlas. High contrast adds and removes a resource pack.
The guard that silences settings loaded at startup
OptionInstance.set is not an unconditional write. It first asks whether
Minecraft.running is true, and when the game is not running it assigns the
field and skips both the equality test and the listener. That is not a
special path for loading: it silences any set performed before that flag is raised, part-way through the Minecraft constructor, and loading happens to be the biggest thing that happens there.
The ordering is what makes it bite. Options are read from disk inside the
Options constructor, which runs inside the Minecraft constructor, five
statements before Minecraft.running is set true (the client
loop has the rest
of that sequence). So every value that comes out of options.txt is installed
with its side effect suppressed — no atlas thrown away, no window resized, no
preset marked custom — and the game instead arranges for the startup state to
be right by other means. A setting whose only effect is in its listener is
therefore a setting that does nothing until you change it in the interface.
Who is told, and what is not said
Options.buildPlayerInformation assembles a ClientInformation: language,
view distance, chat visibility, chat colours, skin model parts, main hand,
text filtering, listing permission, particle status. Nine fields, and
simulation distance is not one of them — the record has no place to put it.
The packet is ServerboundClientInformationPacket, and it is a common
packet rather than a play one: the first is sent during configuration,
straight from ClientHandshakePacketListenerImpl, before the play phase
exists. Every later one comes from Options.broadcastOptions.
There is no acknowledgement, and the absence is the point. What the packet provokes goes outward, and none of it answers you: a hat-visibility update broadcast to the whole player list, you included, and — because
ServerPlayer.updateOptions writes two of the nine fields into synched data
rather than into fields of its own — the skin-customisation byte and the main hand travel to you and everyone tracking you as synched entity
data. The other
seven are read off the ServerPlayer by whoever needs them. Nothing in any of
it tells you what happened to what you asked for. The only thing that ever
sets Options.serverRenderDistance is the server announcing its own view
distance — in the login packet, or by broadcasting
ClientboundSetChunkCacheRadiusPacket whenever that distance changes. Your
request is clamped by ChunkMap.getPlayerViewDistance and used for chunk
tracking, and you are never told what it was
clamped to; the client clamps itself, with
Options.getEffectiveRenderDistance. ClientboundSetSimulationDistancePacket
is likewise an announcement, not a reply.
Singleplayer short-circuits half of it. IntegratedServer.tickServer reads
both the simulation and render sliders off the client’s options every unpaused
server tick and pushes them into the player list, so both drive the server
directly without waiting for a packet. Render distance travels as client
information anyway — it is field two of the record, sent over the memory
connection exactly as over a socket — and simulation distance never does, in singleplayer or out of it, because the record has no place for it.
Questions players ask
Why does my render-distance slider stop short? Its maximum is computed
once, from the JVM’s maximum memory, and the option is a plain
OptionInstance.IntRange built around that bound. It depends on your heap,
not on your graphics card. (The genuinely lazy
OptionInstance.ClampingLazyMaxIntRange is GUI scale’s, and reads the
window.)
Why did none of my settings’ side effects run at startup? Because
Minecraft.running is still false. OptionInstance.set checks it and, when
the game is not running, assigns the field and skips both the equality test
and the listener. That is not a special path for loading — it silences any
set performed before that flag is raised, part-way through the Minecraft constructor, and loading happens in the Options
constructor, which runs inside the Minecraft constructor five statements
before Minecraft.running is set.
Which settings really need a restart? One on the settings screens, and it says so. The graphics backend is compared against a snapshot taken at startup, and Options.isRestartRequiredToApplyVideoSettings is what the screen asks; exclusive fullscreen applies at once. Related: Options.startedCleanly is written false at startup
and true once the game is up, so a crash during boot can drop the client back
to a safe backend.
Why does the tutorial never come back? Options are per installation,
never per world — including Options.tutorialStep. Once any world drives the
tutorial to the end, no later world shows the toasts again, and an
unrecognised step name silently reads as finished.
I edited options.txt and broke it. Why did nothing complain? Failure is quiet by design: a bad line is logged and skipped, an OptionInstance value that will not decode is logged and ignored and one that decodes but lies outside the option’s values is reset to the initial value, a bad number keeps the current one, and a plain boolean field that is not true reads as false. A malformed resource-pack list, or an option value that is not well-formed JSON, ends the load, and every option after it keeps its default. Nothing about a corrupt options.txt stops the game starting. The file carries a version line and is run through the data
fixer on load.
Where to look
Options.processOptions for the settings table. OptionInstance.set for the
listener guard that explains the loading behaviour, and
OptionInstance.CycleableValueSet for the button that saves on click.
Options.buildPlayerInformation for the nine fields the server hears.
ChunkMap.getPlayerViewDistance for what the server does with the one that
matters, and IntegratedServer.tickServer for the singleplayer back door.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
GUI and screens
Verified against Minecraft 26.3 · Part X · pressing E: a screen the server is not told about until you close it, opened onto a menu that was built when you spawned.
Press E in survival and no packet is sent, no packet is received, and nothing
on the server changes. Player.inventoryMenu has existed since the player
object was constructed, it has no MenuType at all, and MenuScreens
could therefore never build an InventoryScreen from a packet even if one
arrived. The opening is entirely a client-side event. Press E again and the
symmetry breaks: LocalPlayer.closeContainer sends
ServerboundContainerClosePacket, and the server empties your 2×2 crafting
grid on the way out, returning or dropping what was in it. The screen the server is never told about is one the
server is told about exactly once, at the end.
The menu underneath it is not symmetric in the same way, and the qualification
matters: InventoryMenu is constructed on both sides, and its crafting
result is recomputed only on the server — so the client is rendering a result
it did not compute, in a screen the server does not know is open.
This page is what a screen is: the manager that holds one, the lifecycle it
runs through, the widget and layout families it is built from, and the four
routes by which one comes to exist. How its contents become pixels is the
GUI render tree; how a Component becomes glyphs is
text and fonts.
The cast
| class | what it decides | thread |
|---|---|---|
Gui | which screen and which overlay exist, and what an absent screen means | Render thread |
Screen | one screen’s lifecycle, children, focus and narration | Render thread |
AbstractWidget | the final outer shape of a widget, and one inner hook per subclass | Render thread |
Layout over LayoutElement | where widgets end up, re-arranged on most screens whenever the window changes | Render thread |
AbstractContainerScreen | a screen mirroring a server-side menu, and the slot geometry | Render thread |
MenuScreens | MenuType to screen class — the registry ClientboundOpenScreenPacket looks a screen up in | Render thread |
Overlay | takes the screen’s place in the record pass, and suppresses its mouse and its typing — but not its key presses | Render thread |
ScreenNarrationCollector | what has already been said, so it is not said twice | Render thread |
The objects, and what contains what
classDiagram
class Gui {
one per game
Screen screen
Overlay overlay
Hud hud
}
class Screen {
List~GuiEventListener~ children
List~Renderable~ renderables
List~NarratableEntry~ narratables
}
class AbstractWidget {
usually in all three lists
}
class AbstractContainerScreen {
AbstractContainerMenu menu
}
class Layout {
arranges; most screens re-arrange it on resize
}
Gui *-- Screen
Gui *-- Overlay
Gui *-- Hud
Screen *-- AbstractWidget
Screen <|-- AbstractContainerScreen
AbstractContainerScreen *-- AbstractContainerMenu
Layout ..> AbstractWidget : positions
A diamond is containment and a hollow arrowhead is inheritance, which the
flowchart this replaced could not tell apart: AbstractContainerScreen is
a Screen, and everything else here holds what it points at. Layout is the dashed one because it arranges widgets the screen owns.
The three lists on Screen are the shape worth remembering: a widget added
with Screen.addRenderableWidget joins all three, and the sibling add methods
exist precisely so that something can be in one or two of them and not the
rest. A Tooltip is in none of them — it is held by a WidgetTooltipHolder
— and MultiLineLabel is an interface rather than a widget at all.
Gui, which is not the HUD
Gui owns Gui.screen and Gui.overlay — set through Gui.setScreen and
Gui.setOverlay — plus Gui.hud, Gui.toastManager, Gui.chatListener,
Gui.splashManager and the reference to the frame’s render state. It has
three cadences, not two: Gui.tick once per client tick, Gui.update
once per frame — which advances toasts and fires delayed narration — and
Gui.extractRenderState once per frame in the record pass. The rest of its
surface is Gui.isPausing, Gui.handleKeybinds (the share of the drain Minecraft.handleKeybinds hands it), Gui.openChatScreen,
Gui.canInterruptScreen, Gui.buildInitialScreens and
Gui.setClientLevelTeardownInProgress.
Three of its behaviours are the sort of thing a reader assumes and gets wrong.
Gui.setScreen with a null screen does not mean “close the screen”. It means “decide
what should be up instead”. With no level it substitutes the title screen;
with a dead player it substitutes the death screen, or respawns; otherwise it
restores the chat screen if one was saved. During a level teardown it throws,
rather than return you to a world that is being dismantled.
Gui.isPausing is the screen’s vote on whether the game stops, and it is
one of three conjuncts: the client
loop
owns the pause itself and the other two, and the vote is cast by Screen.isPauseScreen, which defaults to true and which AbstractContainerScreen.isPauseScreen answers false. An overlay pauses by
default too, which the loop page does not say, and is why a resource reload
stops a singleplayer world as surely as the options screen does.
An overlay does not stack on a screen: in Gui’s record pass it replaces it. Only LoadingOverlay itself records the screen beneath it, while it fades in and out, and the input rules are split — an overlay
suppresses the screen’s mouse and its typing, but not its key presses.
LoadingOverlay is the only implementation of Overlay in the game.
The lifecycle, and what is final
There are two methods called Screen.init and the difference between them is
the lifecycle. The one that takes a width and a height is final — the
framework’s entry point, which nothing overrides — and the no-argument one is the hook a screen implements to build its widgets. Wherever this page says
the Screen.init hook it means the second.
A resize goes through Screen.resize to Screen.repositionElements. The
default Screen.repositionElements rebuilds every widget through
Screen.rebuildWidgets, which re-enters the hook — so on a plain screen
everything really is rebuilt. Forty-one screens override
Screen.repositionElements instead and keep their widgets, most of them just
re-arranging their Layout. “Everything is rebuilt on resize” is true of a
plain screen and false of most interesting ones.
The rest of the lifecycle is Screen.added, Screen.tick, Screen.removed
and Screen.onClose, and the record entry point is the final
Screen.extractRenderStateWithTooltipAndSubtitles.
That pairing is the framework’s whole style: fix the outer shape, hand the
subclass one inner hook. Screen.init’s two-argument form,
Screen.extractRenderStateWithTooltipAndSubtitles,
AbstractWidget.extractRenderState, AbstractWidget.updateNarration,
AbstractButton.extractWidgetRenderState and AbstractContainerScreen.tick
are all final; AbstractWidget.extractWidgetRenderState and
AbstractButton.extractContents are the hooks they leave open. The widget
family under them is Button, EditBox, Checkbox, CycleButton,
AbstractScrollArea and the selection lists over it —
AbstractSelectionList, ObjectSelectionList,
ContainerObjectSelectionList and OptionsList.
Layout is Layout over LayoutElement: LinearLayout, GridLayout,
FrameLayout, EqualSpacingLayout, HeaderAndFooterLayout and
SpacerElement, configured by LayoutSettings and resolved by
Layout.arrangeElements and Layout.visitWidgets. Input arrives as the
client/input records — KeyEvent, MouseButtonEvent and their siblings,
which input and keybinds owns — through
GuiEventListener and ContainerEventHandler; focus is a ComponentPath moved by a
FocusNavigationEvent, ordered by TabOrderedElement.getTabOrderGroup; and
geometry is ScreenRectangle, ScreenPosition, ScreenAxis and
ScreenDirection.
One consequence of doing all this in a record pass:
AbstractWidget.extractRenderState computes hovered as “inside my
rectangle and inside the current scissor”, so a widget scrolled out of a
list does not light up.
Pressing E
sequenceDiagram
participant KH as KeyboardHandler
participant MC as Minecraft
participant Gui as Gui
participant InvS as InventoryScreen
participant MPGM as MultiPlayer<br/>GameMode
rect rgba(0, 0, 0, 0.04)
Note over KH,MPGM: the key press, then the tick that spends it
KH->>KH: keyPress — no screen open, so the mapping records a click
MC->>MC: handleKeybinds, with no screen and no overlay
MC->>MPGM: isServerControlledInventory — false on foot
MC->>Gui: setScreen — a new InventoryScreen, and the input housekeeping
Gui->>InvS: added, then init
InvS->>InvS: init — creative? replace myself with<br/>CreativeModeInventoryScreen
end
rect rgba(0, 0, 0, 0.04)
Note over KH,MPGM: the next frame, recording
Gui->>InvS: extractRenderState<br/>WithTooltipAndSubtitles
InvS->>InvS: extractBackground — a gradient, no blur, no panorama
InvS->>InvS: extractContents, then labels, slots, highlight, carried item
end
Two bands: everything that makes the screen exist happens in the tick that spends the key, and nothing is recorded until the frame that follows it. Gui.setScreen’s housekeeping — the mouse ungrabbed, every mapping released, sneak with them, held or toggled — runs before Screen.init.
The busiest screen in the game is AbstractContainerScreen, and its record
pass is worth following once: AbstractContainerScreen.extractContents draws
the widget list, translates to the container origin, and runs
AbstractContainerScreen.extractLabels,
AbstractContainerScreen.extractSlots and the two slot-highlight passes;
then AbstractContainerScreen.extractCarriedItem, then
AbstractContainerScreen.extractTooltip. It holds
AbstractContainerScreen.menu, AbstractContainerScreen.leftPos,
AbstractContainerScreen.topPos, AbstractContainerScreen.hoveredSlot and
the quick-craft state, and a click goes
AbstractContainerScreen.slotClicked to
MultiPlayerGameMode.handleContainerInput — see containers and
menus,
which also owns the fact this page’s second paragraph turns on: the client
never computes a crafting result.
The final AbstractContainerScreen.tick closes the container when the player
is dead or removed. The client notices first.
Who opens a screen
| route | examples |
|---|---|
| entirely client-side | TitleScreen, PauseScreen, OptionsScreen, ChatScreen, social interactions, the survival and creative inventories |
ClientboundOpenScreenPacket | every menu with a MenuType — chests, furnaces, anvils, and a chest boat |
ClientboundMountScreenOpenPacket | a horse’s or a nautilus’s own inventory |
| other packets | BookViewScreen, AbstractSignEditScreen, DeathScreen, WinScreen, the demo popup, LevelLoadingScreen, dialogs |
Three entities implement HasCustomInventoryScreen and they do not agree
about which packet to use: AbstractHorse and AbstractNautilus send the
mount packet, while AbstractChestBoat opens an ordinary menu like any other
container.
Every screen in that table is an example, and the book keeps them that
way. There are two hundred-odd classes in client/gui/screens and its
eighteen sub-packages — the world-creation flow, the pack picker, the report
screens, the friends and social lists, the recipe book, a class per container
— and they are the single largest thing in Part X by line count. Every one of
them is this section’s four routes, the lifecycle above and the widget and
layout families above, and nothing else; the book explains the pattern once,
here, and names a screen only where some other page’s scenario walks into
it. The
exception it does not cover is player reporting, declined for its own
reasons in what this book
skips.
The screens you see first are a chain rather than a screen.
Gui.buildInitialScreens composes accessibility onboarding, ban notices, a
forced name change and a banned-skin notice ahead of the title screen or a
quick-play launch. And Minecraft.setScreenAndShow sets a screen and then
renders one frame on the spot — synchronously — which is how progress appears during blocking main-thread work such as a world load; a backup or a file fix shows its screen the same way and then runs on the worker pool.
Narration, finally, is mostly timed rather than immediate — mostly, because
Screen.init narrates the new screen at once before arming anything. What
does the speaking is GameNarrator, a thin wrapper over Mojang’s text2speech
library with a four-valued NarratorStatus option in front of it and two
tempers: the queued methods, which yield to whatever is already being said,
and GameNarrator.saySystemNow, which interrupts.
Thereafter Screen.handleDelayedNarration fires from Gui.update once two
clocks have both run out: a next narration time, armed longer after a mouse
move than after a keyboard action, and a suppression time, armed by the two-argument Screen.init on every Gui.setScreen — two seconds ahead, or, when one screen replaces another after Tab or arrow-key navigation, until the next key press or click. Three intervals, two clocks. When both have
passed it picks a single widget to narrate, by tab-order group and
priority.
For a 1.21-era reader. Minecraft.screen is gone: the current screen belongs to
Gui, which is now the screen-and-overlay manager rather than the HUD — the HUD isHud, reached asGui.hud. Gone with it: Minecraft.setScreen, Screen.render and renderBackground, AbstractContainerScreen.renderBg / renderLabels / renderSlot, AbstractWidget.renderWidget, ClickType (nowContainerInput) and MultiPlayerGameMode.handleInventoryMouseClick (nowMultiPlayerGameMode.handleContainerInput). A screen no longer draws — it records.
Where to look
Gui.setScreen — the substitution tree, and the input housekeeping at both
ends of a screen’s life that input and
keybinds
explains. Screen.init and Screen.resize for the lifecycle,
Screen.extractRenderStateWithTooltipAndSubtitles for the record pass, and
Gui.extractRenderState for the frame’s contributor order.
AbstractContainerScreen.extractContents for the busiest screen in the game,
and MenuScreens for the screen registry the menu types use — DialogScreens is another.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
The GUI render tree
Verified against Minecraft 26.3 · Part X · a chest full of the same item: how the 2D UI decides what is in front of what, without anybody ever saying so.
Nothing in the client’s 2D UI draws anything. Every call a screen, a widget or the HUD makes appends a render state to a tree; later in the same frame a second pass resolves that tree, sorts it, batches it and issues the draws. And the tree is not told what order to put things in. Layering is inferred from bounding boxes: a new element goes above the highest existing element whose box it intersects, and two elements that do not overlap can share a node and be reordered freely by the batching sort. There is no Z value, no layer index, and no declared order beyond the call order and two explicit barriers.
That inference is what buys the cheapness of a chest full of identical stacks, and it buys it twice over. Because two elements that do not overlap may share a node, the fifty-odd slots of a chest land in the same layer, and a layer’s element list is free to be reordered — so they can be sorted together and issued as one draw call instead of fifty. And because each distinct item model that is neither animated nor oversized is rendered into a dynamic atlas once and reused for as long as it stays resident, those fifty slots between them cost one 3D render ever, not one per frame and not one per stack; what they then draw is a flat rectangle of that atlas, which is exactly the kind of thing the sort can batch. The model being rendered belongs to models and atlases, and what records into this tree in the first place — screens and widgets — is GUI and screens, the lecture before this one.
The cast
| class | what it decides | thread |
|---|---|---|
GuiGraphicsExtractor | what a screen is handed — every drawing verb, and the scissor stack | Render thread |
GuiRenderState | the tree: strata, nodes, and where a new element belongs | Render thread |
GuiRenderState.Node | one layer: five lists, of which only the element list is ever sorted | Render thread |
GuiElementRenderState | one blit, rectangle or glyph: the kind the mesh builder batches | Render thread |
GuiRenderer | resolving, sorting, coalescing and issuing the draws | Render thread |
GuiItemAtlas | which item models are already rendered, and which give up their slot | Render thread |
GameRenderState | who actually owns the tree — not Gui; the frame’s own snapshot (the frame) | Render thread |
The tree, and where a new element lands
flowchart TD
NEW["a new element arrives"]
NONE{"has bounds?"}
DROP["silently discarded"]
FAST{"does the previous element's box contain it?"}
NEW --> NONE
NONE -- "no" --> DROP
NONE -- "yes" --> FAST
subgraph GRS["GuiRenderState"]
subgraph S0["stratum 0, closed"]
N0["Node"]
end
subgraph S1["stratum 1, the floor"]
N1["Node"]
N2["Node, above it"]
N1 --> N2
end
end
FAST -- "yes: straight up, no intersection test" --> N2
FAST -- "no: up to just above the highest box it touches" --> N2
GuiRenderState is a list of strata and each stratum a chain of nodes. Both
answers land in the tree, and neither can land below the barrier: a GuiRenderState.nextStratum call closes stratum 0 to every later recording, so the worst the search can do is reach the bottom node of the current one.
Three consequences fall straight out of that picture.
An element with no bounds is silently discarded. Every recording add
verb is conditional on the tree finding a node, and finding a node requires
bounds. Glyph states deliberately have none — which is exactly why they are
added through the layer-bypassing verb,
GuiRenderState.addGlyphToCurrentLayer, which the draw pass calls rather than
the record pass, and emitted after their node’s geometry. Glyphs are never sorted, and
that is the whole mechanism behind “text draws on top of its own background”.
The search never descends below the current stratum. A stratum is a
floor the layering search may not go under: opening one declares that
everything recorded from now on sits above everything recorded so far,
whatever the bounding boxes say. That is what makes
GuiRenderState.nextStratum — reached by a recorder as
GuiGraphicsExtractor.nextStratum, which is the same barrier under the name
the caller sees — a hard rule rather than a hint. It is how the HUD keeps the
hotbar block out of the crosshair’s layering, and how chat stays clear of the
scoreboard.
The fast path is the common case. If the previous element’s box contains the new one — a label inside a button, a sprite inside a slot — it goes straight up with no test.
The tree’s verbs sort into three kinds, and the third kind is the one that catches people out: two of them are not recording verbs at all, and are called by the resolve pass rather than by a screen.
| verb | who calls it | what lands in the node |
|---|---|---|
GuiRenderState.addGuiElement | a recorder | BlitRenderState, TiledBlitRenderState, ColoredRectangleRenderState |
GuiRenderState.addText | a recorder | GuiTextRenderState |
GuiRenderState.addItem | a recorder | GuiItemRenderState |
GuiRenderState.addPicturesInPictureState | a recorder | five of the PictureInPictureRenderState family, below |
GuiRenderState.nextStratum | a recorder | nothing — a barrier |
GuiRenderState.blurBeforeThisStratum | a recorder | nothing — a barrier |
GuiRenderState.addBlitToCurrentLayer | GuiRenderer and PictureInPictureRenderer, in the resolve pass | the flat rectangle an item or a picture-in-picture resolved to |
GuiRenderState.addGlyphToCurrentLayer | GuiRenderer, in the resolve pass | GlyphRenderState |
The last two bypass the layering search entirely and append to the node their
source element already chose, which is the whole reason a resolved thing never jumps in front of what recorded it. PanoramaRenderState has no verb at all: the title screen’s spinning backdrop is a nullable field on
GuiRenderState, assigned by Panorama and drawn by GuiRenderer.render
before the tree is resolved — so it is under everything by construction rather
than by layering.
The PictureInPictureRenderState family is six — GuiEntityRenderState,
GuiSkinRenderState, GuiBookModelRenderState, GuiBannerResultRenderState,
GuiProfilerChartRenderState and OversizedItemRenderState — and each has a matching PictureInPictureRenderer subclass in client/gui/render/pip that resolves it into a texture during GuiRenderer.prepare. Five are recorded; the sixth, OversizedItemRenderState, is built by the resolve pass itself for an item drawn larger than its slot, and has no verb either. Those six, the item atlas and the panorama’s cube are where 3D drawing happens inside a 2D pass: the entity in an inventory that turns to follow the mouse, the skin on the skin-report screen, the enchanting-table book, and every item model in every slot.
The draw pass
Two phases, one thread, one frame. It is a data split rather than a thread split: the record phase touches game state, the draw phase touches the recorded objects and the GPU.
flowchart TD
REC["Gui.extractRenderState — the contributors record"]
subgraph RENDER["GuiRenderer.render"]
subgraph PREP["GuiRenderer.prepare"]
PIP["pictures-in-picture, to textures"]
ITEM["items, into GuiItemAtlas"]
TEXT["text, expanded per glyph"]
SORT["GuiRenderState.sortElements"]
MESH["GuiRenderer.addElementToMesh, coalescing Draws"]
PIP --> ITEM --> TEXT --> SORT --> MESH
end
UP["the vertex buffer uploaded"]
subgraph DRAW["GuiRenderer.draw"]
BEFORE["everything before the blur"]
BLUR["clear depth, run the post effect"]
AFTER["everything after"]
BEFORE --> BLUR --> AFTER
end
MESH --> UP --> BEFORE
end
END["GuiRenderer.endFrame — GameRenderer's call"]
REC --> PIP
AFTER --> END
The nesting is the point: the five steps in the inner box all happen inside
GuiRenderer.prepare, not after it, and GuiRenderer.draw is its sibling
rather than its successor. Only Gui.extractRenderState at the top and
GuiRenderer.endFrame at the foot are outside GuiRenderer.render.
One thing happens eagerly during recording that looks like it should not: adding text forces the text to be prepared, because the tree needs its bounds to place it. Only the expansion into per-glyph states waits for the draw pass — see text and fonts.
There is one sort comparator, not three.
GuiRenderer.ELEMENT_SORT_COMPARATOR is the whole key, and it is built from three, in order: GuiRenderer.SCISSOR_COMPARATOR first,
then the pipeline’s own sort key, then GuiRenderer.TEXTURE_COMPARATOR. That
is what turns a node’s element list into as few GuiRenderer.Draws as
possible, because the mesh builder starts a new one only when the next element disagrees about one of those three, or the blur boundary falls between them. Both the sort and the coalescing happen
inside GuiRenderer.prepare; GuiRenderer.draw replays the list they produced, splitting it at the blur, and GuiRenderer.render is the outer method that calls the two in
turn.
Blur is a barrier, and it is fussy
GuiRenderState.blurBeforeThisStratum splits the draw list in two, and
GuiRenderer.draw then draws everything before the boundary, clears the depth
buffer, runs the blur chain over the result — world and GUI alike, which belongs to post-processing — and draws the rest crisp on top. What the tree decides is where the
boundary goes and whether there is one at all.
Screen.extractBlurredBackground is the one thing that asks, and inside it
there is exactly one condition: the menu-background blurriness option must be
at least one, which is why a slider at zero costs nothing at all rather than
blurring by a little. Two further facts are about reaching it. Screens that
declare themselves in-game UI — container screens, sign editors, book screens
— take the transparent-background path and never call it, which is why the
pause menu blurs the world and a chest does not. And a second call in one frame
is a bug rather than a second blur: GuiRenderState.blurBeforeThisStratum
throws when the boundary has already been set. The darkening tint over a blurred menu is itself sharp, because the boundary falls before the whole stratum it is recorded in.
When the atlas costs something, and what the extractor keeps
The residency the opening leans on is not free, and its failures are worth
knowing because they are what a slow inventory screen looks like. A slot that
is stale or was never filled is redrawn with no invalidation involved, and
animated models are exempt from residency altogether — they are redrawn every
frame. Wholesale invalidation is the loud case: changing the GUI scale throws the atlas away, and so does growing it, after which every model the frame draws is rendered again; an atlas that cannot grow, already at the largest texture the device allows, logs that some items will be skipped rather than failing. No slot ages out: GuiRenderer.endFrame, which GameRenderer calls after the frame, frees only the animated models’ slots, and otherwise a resident model gives up its slot only when a later frame’s models do not fit beside it, and then DynamicAtlasAllocator, the grid of equal slots behind GuiItemAtlas, frees slots of models that frame does not draw to make room.
The extractor, for its part, is not quite the pure function its name promises.
It keeps real state and makes one side effect of its own, and neither is a draw: the pose and scissor stacks are state, and the cursor shape requested during the record pass is applied to the window at the end of it. It also holds the deferred tooltip and the IMEPreeditOverlay until the screen has recorded everything else, then records each in a stratum of its own, which is how both land above everything the screen drew.
And the tree outlives all of it, because it is not Gui’s. Gui holds a
reference to one that belongs to GameRenderState, so the tree is reachable
from the frame rather than from the interface — which is also how two fields recorded here come to change the frame: the
HUD owns that traffic and both of its
directions. If you ever need to prove the batching sort is safe, two debug switches exist for it: one draws a translucent tint over every element’s box, in the layer above it, and the other shuffles each node’s element list and re-seeds the
sort keys, to shake out accidental order dependence.
For a 1.21-era reader. There is no GuiGraphics. The class is
GuiGraphicsExtractor, and the name is the whole design — it extracts, it does not paint. LayeredDraw is gone too: ordering is inferred from the boxes and the call order, plus explicit barriers. GuiGraphics.renderTooltip is gone, and so isPoseStackin 2D GUI code — the GUI transform is a 2D affine stack now, though a realPoseStackstill lives inside the item atlas and the picture-in-picture renderers, where actual 3D models are drawn.
Where to look
GuiRenderState.nextStratum and the node-placement logic beside it — the layering rule is short and explains most of the UI’s behaviour.
GuiGraphicsExtractor for what a screen is handed.
GuiRenderer.prepare for where items, text and picture-in-picture content are resolved and the draws are batched, and GuiRenderer.draw for the blur split.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Text and fonts
Verified against Minecraft 26.3 · Part X · a chat line in a language whose characters are not in the default font: six stages from a
Componentto a quad, one of which uploads a texture while pretending to measure.
Ask the client how wide a piece of text is and it will stitch a glyph into a
texture sheet and upload it to the GPU. Font.width resolves each codepoint
to a baked glyph and reads its advance — so measuring a codepoint nobody
has drawn yet is neither free nor read-only, and a layout pass over a
never-before-seen script is a series of texture uploads wearing arithmetic’s
clothes.
This page is the pipeline that ends in that glyph: flattening a component
tree into styled runs, measuring and wrapping it, reordering it for
bidirectional scripts, choosing a glyph for every codepoint from a chain of
providers, baking it the first time anyone asks, and emitting one renderable per glyph for the draw pass to turn into vertices.
What a Component is — its contents kinds, its Style, how it is
serialised — is Part II’s text
components, and how a chat message is
signed is chat and signing. This page
starts from “you have one”.
The cast
| class | what it decides | thread |
|---|---|---|
StringDecomposer | where one styled run ends and the next begins, and what a section sign means | Render thread |
StringSplitter | width, line breaks and word boundaries, given a width provider | Render thread |
FormattedBidiReorder | visual order, by handing the flattened text to ICU | Render thread |
FontManager | which GlyphSource a FontDescription resolves to — three different ways | worker pool, then Render thread |
FontSet | the provider chain for one font id, its codepoint cache, and the fishy split | Render thread |
GlyphStitcher | which FontTexture sheet a bitmap goes to — the sheet places and uploads it | Render thread |
Font | the per-codepoint loop, and the Font.PreparedText everything else walks | Render thread |
GuiRenderer | expanding prepared text into one GlyphRenderState per glyph | Render thread |
The six stages
flowchart TD
C["a Component"]
subgraph WHEN["when the text is laid out"]
RUNS["1 · flatten — Component.visit, then Style.applyTo"]
DEC["StringDecomposer, codepoint by codepoint"]
WRAP["2 · measure and wrap — StringSplitter"]
BIDI["3 · reorder — Language.getVisualOrder, ICU"]
RUNS --> DEC --> WRAP --> BIDI
end
subgraph PREP["inside Font.prepareText"]
RESOLVE["4 · resolve — FontManager, then the first provider"]
BAKE["5 · bake — first sight only, into a FontTexture"]
EMIT["6 · emit — a Font.PreparedText of TextRenderables"]
RESOLVE --> BAKE --> EMIT
end
GUI["at draw: GuiRenderer expands it into GlyphRenderState, or TextFeatureRenderer walks it for the world"]
WORLD["or SubmitNodeCollection.submitText, for the world"]
C --> RUNS
BIDI --> RESOLVE
WRAP -. "measuring resolves and bakes it too" .-> RESOLVE
EMIT --> GUI
BIDI --> WORLD
WORLD -. "TextFeatureRenderer runs four to six at draw" .-> RESOLVE
The numbers are the order the stages matter in, not one walk: for a chat line the top box runs when the line arrives and whenever the chat re-wraps, the bottom box on every record pass for the GUI and at draw time for text in the world, and the first dotted edge is why measuring a string you never draw still uploads its glyphs. In the GUI only what happens after stage six waits for the draw pass.
The numbering is the order the stages matter in, not a pipeline anything walks straight through. Stages one to three run when a chat line arrives (some callers run them on every frame), and four to six run inside Font.prepareText, which the GUI calls during the record pass — the render
tree explains
why, and why nothing is left for the draw pass but the expansion of the
finished Font.PreparedText into one GlyphRenderState a glyph. But four and five are
also reached from two: the width function Font hands its StringSplitter
asks the glyph source for each codepoint, and that call resolves the provider
and forces the bake. Measuring a string you never draw still uploads its
glyphs.
All of it is on the Render thread, glyph baking and GPU uploads included. The
only work that leaves the thread is loading, and it is an ordinary resource
reload with
one unusual step: FontManager’s prepare phase parses the font definitions,
loads each provider, resolves references between them, and then pre-warms the chain by asking it for every codepoint but the space that any provider claims, on the worker pool, each answered by the first provider that has it — so a TrueType font’s glyphs are loaded off the Render thread before the apply phase asks for every one of them again to build the by-width table. Baking still waits for first use, except for the missing box and the white effect glyph, which every reload bakes at once. The apply phase — closing the old font sets
and building new ones — is back on the Render thread.
1 · Flatten
Component and FormattedText provide the walk: Component.visit yields
(style, string) runs in logical order, applying Style.applyTo down the
sibling tree. StringDecomposer iterates those runs codepoint by codepoint
and is where a legacy section-sign colour code is interpreted.
FormattedCharSequence is the end product — a one-method interface that
pushes (index, style, codepoint) triples at a FormattedCharSink — and
ComponentCollector reassembles pieces back into formatted text.
2 · Measure and wrap
StringSplitter owns width and line breaking: StringSplitter.stringWidth,
StringSplitter.splitLines, StringSplitter.headByWidth,
StringSplitter.findLineBreak and StringSplitter.getWordPosition. It works
against a StringSplitter.WidthProvider, and on the real Font that
provider is what bakes. Font.width, Font.split,
Font.splitIgnoringLanguage, Font.wordWrapHeight and Font.getSplitter
are the public face of it.
Wrapping preserves styles rather than ignoring them. It cuts at a
character offset, captures the style in force at the cut, and re-applies it to
the continuation — which is why a colour code before a wrap point still
colours the line after it. And
Font.split reorders while Font.splitIgnoringLanguage does not — anything that will re-wrap must use the second.
3 · Reorder
Language.getVisualOrder is the entry point; on the client ClientLanguage
implements it with FormattedBidiReorder, which builds a SubStringSource —
the flattened plain text plus one Style per character — runs ICU’s Arabic shaping and bidi
algorithm over it, and re-emits each run through
SubStringSource.substring. MutableComponent.getVisualOrderText caches
the result against the identity of the current Language.
Font.bidirectionalShaping is a separate, much smaller thing: it shapes and reorders a bare string, and beside Font.prepareText’s own use of it the sign editor is
the only caller. Those two are half of the game’s ICU surface; the other two
are not text layout at all — CreateBuffetWorldScreen and the item
property LocalTime each use it for collation and calendars.
4 · Resolve
FontManager is the reload listener and the resolver: Font.Provider asks
it for a GlyphSource given a FontDescription, and it branches once per
kind of description — three of them.
A FontDescription.Resource resolves to a FontSet — the per-font-id object
holding the provider list, the codepoint cache (CodepointMap), a
GlyphStitcher and the by-width table used for obfuscation. A
FontDescription.AtlasSprite or FontDescription.PlayerSprite resolves
instead to a SingleSpriteSource from AtlasGlyphProvider or
PlayerGlyphProvider — a one-glyph font that returns the same sprite for every codepoint, with no texture sheet of its own; only an atlas id nobody knows falls back to the missing-font set.
Below that: GlyphProvider implementations chosen by GlyphProviderType,
declared in font/ JSON as GlyphProviderDefinitions. Five kinds, and their
definitions are worth naming because the differences between them are visible
on screen: BitmapProvider slices a PNG on a grid, TrueTypeGlyphProviderDefinition
runs FreeType over a real font file (FreeTypeUtil is the wrapper),
UnihexProvider reads the compact hex format the fallback font ships in,
SpaceProvider produces advance and no pixels, and
ProviderReferenceDefinition splices another font’s providers into this one’s
chain. Beside them SpecialGlyphs is not from a file at all: it is the
hard-coded missing-glyph box and the white square everything else draws
effects with.
5 · Bake
A provider returns an UnbakedGlyph, whose GlyphBitmap is handed to
GlyphStitcher.stitch, placed into a FontTexture sheet and uploaded,
producing a BakedGlyph. BakedGlyph is an interface; the sheet
implementation is BakedSheetGlyph, and EffectGlyph covers the solid quads
used for underlines and backgrounds.
There is one glyph atlas family per font id, each with its own stitcher, and colour and greyscale glyphs never share a sheet. The atlas is discarded, never compacted: a resource reload throws it away — and so does toggling the force-unicode or Japanese-variants option, which rebuilds every font set with no reload at all.
6 · Emit
What comes out is a TextRenderable per glyph inside a Font.PreparedText.
In the GUI, GuiGraphicsExtractor.text and its siblings build a
GuiTextRenderState that GuiRenderer later expands into
GlyphRenderStates. In the world, SubmitNodeCollection.submitText and
SubmitNodeCollection.submitNameTag both hand a FormattedCharSequence to TextFeatureRenderer, which runs Font.prepareText itself at draw, and
GlyphRenderTypes.select picks between the
normal, see-through and polygon-offset render types by Font.DisplayMode.
A chat line, through all six
sequenceDiagram
participant ChatC as ChatComponent
participant CRU as Component<br/>RenderUtils
participant SSpl as StringSplitter
participant FBR as Formatted<br/>BidiReorder
ChatC->>CRU: wrapComponents — the chat width over the chat scale
CRU->>SSpl: splitLines — over runs already translated
SSpl->>SSpl: splitLines cuts at a char offset, each run keeping its Style
CRU->>FBR: reorder, reached through Language.getVisualOrder
FBR->>FBR: SubStringSource, ICU bidi, one substring per run
Stages one to three, when the message arrives: four objects, and the measuring has already baked the glyphs it measured (an Arabic line’s shaped forms wait for stage five).
ComponentRenderUtils reaches FormattedBidiReorder through
Language.getVisualOrder, which ClientLanguage implements.
When the frame is next recorded, the same line goes through four other objects.
sequenceDiagram
participant ChatC as ChatComponent
participant Font as Font
participant FSet as FontSet
participant GStit as GlyphStitcher
participant GuiR as GuiRenderer
rect rgba(0, 0, 0, 0.04)
Note over ChatC,GuiR: recording the frame
ChatC->>Font: prepareText, via GuiTextRenderState asking its bounds
loop each codepoint
Font->>FSet: getGlyph — the first provider that has it
FSet->>GStit: first sight only: stitch it and upload
Font->>Font: a TextRenderable, effects, advance the pen
end
end
rect rgba(0, 0, 0, 0.04)
Note over ChatC,GuiR: drawing the same frame
GuiR->>GuiR: walks the Font.PreparedText with a Font.GlyphVisitor
GuiR->>GuiR: one GlyphRenderState a glyph — shadow, bold and italic belong to BakedSheetGlyph.<br/>renderChar
end
Stages four to six, then what happens to their result: the two bands are the
record pass and the draw pass of one frame, and GlyphStitcher appears in the first only for a glyph the measuring did not bake, such as a shaped form, because a glyph is baked once per font set, until a reload or a font option starts it again.
Two moments beyond the baking are worth pausing on. Translation is lazy and
cached on the TranslatableContents itself, so the first thing to visit a message is what translates it — for a chat line, usually the chat listener (a system message’s sender guess, a signed message’s trust check), otherwise the log line ChatComponent writes as it adds the message. And the continuation indent on a wrapped chat line is a
literal space codepoint prepended by ComponentRenderUtils — one of the places the pipeline invents a character rather than deriving it, beside a clipped line’s ellipsis and an object component’s replacement character.
Style is not a font, and the box is four failures
Two things the pipeline does not do are worth stating before the page ends, because both are assumed the other way round by nearly everyone.
Bold is not a font. Bold draws the glyph twice with a small offset and
thickens it; italic shears the top and bottom edges. Nothing anywhere in the
six stages knows what a bold face is. Shadow is a colour rather than a boolean,
and zero means none. Underlines, strikethroughs and text backgrounds always
come from the default font whatever the style names, because the effect
glyph is looked up separately from the text’s. Obfuscation is in the same
family: the glyph is swapped for a random one of the same width, from a
by-width table rebuilt with the font set, which is why obfuscated text does not shift a layout unless the chain has fractional advances or, in bold, mixes providers of different bold offsets — and the swap happens inside Font.prepareText, which already runs once per frame, though each glyph it swaps in is baked the first time it is chosen.
A sprite is a font. A block icon or a player head in a chat line is,
mechanically, one character in a font with one character in it: an object
component emits a single object replacement character with a synthetic font
description, which resolves through stage four to a one-glyph sprite source.
The plain-text walk of the same component yields a bracketed fallback string
instead, which is what the narrator and Component.getString see. A data pack
cannot reach this: the codec behind FontDescription encodes only the resource
kind, so a style can never name a sprite font, and the sprite descriptions
arise only from object contents.
And when the pipeline fails it fails one way, which makes the hollow box
ambiguous. Four different causes produce it: no provider has the codepoint; the
font id is unknown, so the whole font set is the missing-font set; the glyph’s
advance is fishy — outside a sane range — and the caller asked for the
filtered font; or the bitmap fit no sheet. The fishy case is the one with
machinery behind it. Every font set stores two suppliers per codepoint because
of it, and the game builds a second Font that filters the fishy ones out,
with exactly one use in the entire client: the chat input box, which cannot be
made to draw a character three screens wide.
What else walks a Font.PreparedText, and what the caches promise
The prepared text is walked twice by different things and that is deliberate.
GuiRenderer walks it to emit glyphs, and ActiveTextCollector walks the very
same object looking for the style under the cursor — which is how hovering a
link works, and why preparation can be asked to record empty areas, so that
hovering the space inside a hover-event run still finds the style. Glyph areas
deliberately extend to the full advance, so there are no dead gaps between
characters.
The caches down the pipeline are safe mostly by being single-threaded rather
than by being locked: they are meant to be touched only on the Render thread.
It is not a clean rule — some are keyed on identity and some on equality, and
the glyph layer does use volatile fields and a Guava cache. A component’s
cached visual order is invalidated by the Language object changing, and a
translatable component’s decomposition likewise. Two places leak, and both are worth knowing: the sign block entity caches its rendered lines on a class the server also ships, and a text display entity keeps its laid-out lines the same way; neither cache notices a font reload.
For a 1.21-era reader.
Fontcannot draw. Every drawInBatch and drawString is gone;Font.prepareTextreturns aFont.PreparedTextthat somebody else walks later.Style.getFontno longer returns an identifier — it returns aFontDescription, which may not name a font file at all. Also gone: Font.StringRenderOutput; RawGlyph and SheetGlyphInfo (nowUnbakedGlyphandGlyphBitmap); BakedGlyph as a class; GlyphProviderBuilder (nowGlyphProviderDefinition); andFontSet.getGlyphas public API.
Where to look
Font.prepareText — the per-codepoint loop is the centre of the page.
FontSet for the provider chain and the fishy-advance split, FontManager
for how a FontDescription becomes a GlyphSource, and
GlyphStitcher.stitch for the moment a glyph acquires a texture.
StringSplitter.splitLines for how a line break preserves styles, and
FormattedBidiReorder.reorder for the heaviest of the four places ICU is
used.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
The HUD
Verified against Minecraft 26.3 · Part X · press F1 and the interface goes away — but not the thing that can black out your whole screen.
Press F1 and the hearts go, the hotbar goes, the crosshair, chat and the tab
list go. Get into bed with the interface hidden and the screen still fades to
black. That exception is not a special case in the code: it is a
consequence of where a single element sits. Hud.extractRenderState is one
ordered method wrapped in two blocks gated on the hidden flag, and the
sleep fade is recorded in the gap between them.
That is what this page is: not a tour of hearts and hotbars but a policy. What is drawn over the world, in what order, under exactly which conditions, and which of those conditions is a surprise. The full per-element table is what the HUD draws, and when in Reference; the machinery underneath every one of them is the GUI render tree, and what a screen is — the other thing recorded into the same tree — is GUI and screens.
The order, and the two blocks
flowchart TD
PUB["GuiRenderState.isHudHidden published, before any check"]
LLS{"is a LevelLoadingScreen up?"}
STOP["record nothing"]
H1{"hidden?"}
A["camera overlays, crosshair, a new stratum, the hotbar block, effects, boss bars"]
SLEEP["the sleep fade — ungated"]
H2{"hidden?"}
B["demo text, scoreboard sidebar, action bar, title, chat, tab list, subtitles"]
B2["the subtitles only, and only with an in-game-UI screen up"]
GUI["Gui continues: the screen if any, then saving indicator, toasts, debug overlay, the deferred subtitles"]
PUB --> LLS
LLS -- "yes" --> STOP --> GUI
LLS -- "no" --> H1
H1 -- "no" --> A --> SLEEP
H1 -- "yes" --> SLEEP
SLEEP --> H2
H2 -- "no" --> B --> GUI
H2 -- "yes" --> B2 --> GUI
Two identical hidden? gates with the sleep fade between them, which is the whole shape: the fade is the only thing Hud.extractRenderState records whatever you press. Both branches reach the subtitles: the visible one always, deferring them when there is no screen or an in-game-UI one, and the hidden one only with an in-game-UI screen up.
Above all of that sit gates that are not Hud’s at all, and a page about
conditions has to name them: GameRenderer.extract combines whether resources are loaded, whether this frame advances game time (which it is handed) and whether there is a level, and Gui.extractRenderState applies them, calling Hud.extractRenderState only when they hold. That is why a HUD-less frame is the normal state of a loading screen shown before a level exists; the level-loading screen, which is up while one does, is the one Hud tests for by name.
Four more elements are recorded by Gui itself, after the screen rather than
with the rest of the HUD, and they are gated at three different depths — which
is the one thing about them worth memorising, because it decides what you see
on a title screen, on a loading screen and under F1.
| recorded after the screen | when Gui records it | under F1 |
|---|---|---|
| the saving indicator | only on a frame that renders a level | drawn — the one element that ignores the flag |
| the toasts | whenever resources are loaded, level or not | hidden |
| the debug overlay | whenever resources are loaded and some debug entry is on, unless the debug-options screen is the one open | hidden, unless some screen is open |
| the deferred subtitles | always | whatever Hud’s second block decided |
Two structural facts follow from the shape. The hidden flag is published
before the loading-screen short-circuit, so the renderer’s copy is correct
even on a frame where the HUD records nothing. And toasts and the debug
overlay are always above a screen, because Gui records them after it —
while the deferred subtitles are called from a screen’s background pass, so
the deferral that fires after the screen finds nothing left to draw and the
subtitles a screen drew for itself sit under that screen’s widgets.
Hud.tick runs from Gui.tick once per client tick and takes a pause flag:
the autosave indicator animates either way, everything else only when the
game is not paused.
The cast
| class | what it decides | thread |
|---|---|---|
Hud | the order, the two hidden-gated blocks, and the health animation state | Render thread |
Gui | the four elements recorded after the screen, and their three different gates | Render thread |
ContextualBar | what can occupy the one slot above the hotbar — Hud picks which | Render thread |
BossHealthOverlay | the bars, and three questions the world’s side asks it | Render thread |
ChatComponent | the message list, its wrapped lines, and what is faded out | Render thread |
DebugScreenEntries | the F3 registry: what an entry is, and the presets | Render thread |
DebugScreenEntryList | the per-entry status, the preset applied, and its own save file | Render thread |
GuiGraphicsExtractor | everything the HUD records into | Render thread |
The toast shelf
The toasts are the one element in that list with arbitration of its own, and
it is a shelf rather than a queue. ToastManager holds five slots and a
waiting deque; a toast declares how many consecutive slots it wants through
Toast.occcupiedSlotCount — Mojang’s spelling — and is admitted only when
that many free slots sit next to each other, so a wide toast can wait behind
narrow ones that arrived after it. Toast.Visibility is the two-state
animation, each state carrying its own sound, and a toast is asked its wanted
visibility every frame rather than given a lifetime. Toast.getToken is what lets SystemToast.addOrUpdate reset a system toast with the same id instead of stacking a second, and ToastManager.getToast, which matches class and token, is how a newly unlocked recipe joins the recipe toast already up; advancement toasts always stack. The
implementations are AdvancementToast, RecipeToast, TutorialToast,
SystemToast, FriendToast and NowPlayingToast — the last of which is not
on the shelf at all: it is a field of its own, drawn after the five and
suppressed by the pause screen and by MusicToastDisplayState.
The hidden flag travels two ways
The interesting one is the smaller. GuiRenderState.isHudHidden is read by
GameRenderer in three places, to suppress the held item, the
three-dimensional crosshair and — the smallest of the three — the totem-pop
animation. The block-in-eyes, water and fire overlays are drawn whatever F1
says. So a 2D flag does change how the world is drawn, in three narrow
ways. But Hud.isHidden itself is read directly by six other places across
the client, including two entity renderers that suppress name tags.
It is not the only field on the tree the frame reads back: a clear-colour
override lives there too, and every site that reads either of them is
GameRenderer’s rather than LevelRenderer’s — the 2D side never reaches
into the world renderer, only into the thing that drives it.
This page’s own boss bar is the loudest case of the same traffic. Five places on the world’s side — the game renderer’s tick and its level pass, the lightmap, the fog environment and the music picker on Minecraft — ask BossHealthOverlay three questions: should the screen darken, should world fog be created, should the End music play. So the dragon’s fog and the Wither’s darkening come from the world asking a HUD element, which holds the flags the boss-bar packets set. The bar itself
interpolates against wall-clock time inside LerpingBossEvent, which is what
turns the discrete progress the server sends into a smooth bar; what is on the
other end of those packets — the saved model, its members, and the execute store that writes it — is scores, teams and stored
data.
Four states, one slot, and an asymmetric rule
Hud.contextualInfoBar holds one of four states — nothing, experience, the
locator, a jumpable vehicle — and Hud.nextContextualInfoState re-decides
which every frame. It is a rule, not a state machine, and the rule is not
symmetric. With waypoints present, a jumping vehicle or a recently changed
experience total beats the locator; with no waypoints, a jumpable vehicle
beats experience unconditionally — so mounting a horse silently takes your
XP bar away. The level number is recorded separately from the bar, so it
survives whichever bar wins.
The three implementations are ExperienceBar, LocatorBar — backed by
ClientWaypointManager and styled by Hud.waypointStyles, a
WaypointStyleManager that is the HUD’s own reload listener — and
JumpableVehicleBar. Hud.ContextualInfo is the enum of the four states.
The hearts, which are three numbers at once
sequenceDiagram
participant CPL as ClientPacketListener
participant LP as LocalPlayer
participant Hud as Hud
participant GGE as GuiGraphicsExtractor
CPL->>LP: hurtTo — the health, on a change the cooldown, on damage hurtTime
Note over CPL,GGE: the next frame
Hud->>Hud: extractPlayerHealth, gated on the game mode
Hud->>Hud: healthBlinkTime — 20 ticks, or 10 for a heal
Hud->>Hud: displayHealth catches up after the second
Hud->>Hud: Hud.random takes tickCount, so the jitter is per tick
Hud->>Hud: extractHearts — one descending pass: container, absorption, ghost, truth
Hud->>GGE: blitSprite a heart, chosen by Hud.HeartType.getSprite
One packet, one frame later: the packet’s handler, ClientPacketListener.handleSetHealth, does everything it does to the hearts by calling LocalPlayer.hurtTo, and the six steps after the note belong to Hud. None of them reacts to the packet — it is what the HUD does every frame, reading numbers the packet left behind.
The figure’s steps are Hud.extractPlayerHealth, which sets Hud.healthBlinkTime from Hud.tickCount when the health fell or rose while LivingEntity.damageCooldownTime runs;
Hud.displayHealth, which lags; Hud.random, reseeded from that same tick
counter; Hud.extractHearts, the one descending pass; and one GuiGraphicsExtractor.blitSprite per layer of each heart, the sprite chosen by Hud.HeartType.getSprite. LocalPlayer.hurtTo is what the packet touched: it sets the health, LivingEntity.damageCooldownTime when the health changed, LivingEntity.hurtTime too when it fell, and nothing on Hud at all.
The shake is seeded from the tick counter, so it jitters once per client tick and is identical across every frame of one tick — and the same seeded stream drives the hunger jitter and the air-bubble wobble, which is why each of them holds still within a tick. The blink is a square wave with a three-tick half-period, running for twenty ticks after damage and ten after a heal.
Three numbers and four layers is the arithmetic of a heart row, and they are
not the same list. The three numbers are Hud.lastHealth, which is what the
previous frame saw; Hud.displayHealth, a lagging value that catches up about
once a second; and the truth, read off the player. The four layers the
descending pass draws are the container, absorption, the ghost and the truth —
and the ghost is Hud.displayHealth, the number that is out of date on
purpose. That is what the blink shows you: not damage, but the gap between two
of the three numbers.
Hud.HeartType is six constants, one of which Mojang spells
Hud.HeartType.POISIONED, each with eight sprites.
One gate silences four elements at once: armour, hearts, food and air are all
recorded inside Hud.extractPlayerHealth, which is gated on the game mode
being able to hurt you — which is why creative has no armour bar either. Food and mount health share a slot, and the air bubbles sit a row above it, rising further for each extra row of mount hearts. And the air bubbles make a sound:
Hud.playAirBubblePoppedSound ramps volume and pitch with how many bubbles are
gone, and hands it to the sound
engine
like any other client-side sound. It is the only element inside
Hud.extractRenderState that is also an audio event — the toasts have sounds
too, but they arrive with the toast rather than out of a drawing pass.
Chat is one thing in two modes, and five collections
The chat you read and the chat you type into are the same code in different modes: the HUD bails out entirely when the chat screen is focused. Message age is measured in HUD ticks rather than timestamps, and a message the server asked to delete keeps its original timestamp when it is replaced by a marker — so the marker fades on the original message’s schedule.
ChatComponent holds four collections, not two: every message and every
wrapped line are separate lists with separate caps, and the deletion queue
and the recent-input history are the other two. A fifth, the delay-option
queue, lives on ChatListener, and is the reason a chat-delay setting can hold
a message that has already arrived.
What the message list holds is a GuiMessage — the time it arrived in HUD ticks,
the Component, the signature if it had one, a GuiMessageSource saying
whether a player, the server or this client produced it, and a nullable
GuiMessageTag. The tag is the client’s own verdict, drawn as a two-pixel
bar to the left of the line, outside the text entirely, with the reason as
a tooltip when you hover it: system, system-in-singleplayer, not-secure,
modified, error. Only modified also carries an icon, and that one goes after
the text rather than beside the bar; each tag additionally carries a short GuiMessageTag.logTag string, which goes to the game’s log beside the message. No GuiMessageTag reaches ChatLog, the separate ring of LoggedChatEvents kept for the reporting screens; what it keeps of the verdict is a player message’s ChatTrustLevel, which the modified and not-secure tags are made from. Which verdict a message earned belongs to chat and signing; this page owns the bar.
What a debug line is, and who turns one on
The F3 overlay is a registry rather than a method, and it is the second of the
two debug systems this book describes. DebugScreenEntries holds every entry
by Identifier, each a DebugScreenEntry writing lines through a
DebugScreenDisplayer. DebugScreenEntryList, reachable as
Minecraft.debugEntries, stores a DebugScreenEntryStatus per entry, applies the DebugScreenProfile presets DebugScreenEntries defines and persists to its own file with its own
data-fixer type — so an entry set to always-on renders with F3 never pressed,
and the game remembers that you set it. The screen that edits it is suppressed by Gui rather than by the overlay, and the overlay’s own test asks about screens too: under F1 it still records, given an entry to show, whenever a screen is open.
The charts hanging off it are FpsDebugChart, TpsDebugChart,
PingDebugChart and BandwidthDebugChart over AbstractDebugChart, plus
ProfilerPieChart, which is not one of them.
Two seams run out of here. Two of the rebindable debug mappings toggle entries that print no line at all and exist only to carry a flag the world renderer reads — input and keybinds says which family a given shortcut belongs to. And the other debug system, the one that asks the server for a villager’s brain, is debugging the running game: the two meet because an F3 entry decides whether eleven of that page’s twenty-five renderers exist at all, and the FPS charts are what turn its tick-time subscription on.
Questions players ask
Why do subtitles appear under an open chest? They are deferred to the screen’s background pass, beneath its widgets, whereas the tooltip and the pre-edit overlay the extractor holds are deferred past the widgets. The deferral happens when there is no screen at all or the screen declares itself in-game UI — the common case, not the rare one — and with no screen Gui makes the deferred call itself — or, while the loading overlay fades in or out, LoadingOverlay does.
Is the pumpkin blur hardcoded? No. The camera overlay list is data-driven: every equipment slot is asked whether its item declares a camera overlay.
Why does everything on my HUD vanish at once when I disconnect? Because
one method clears it as a whole. Hud.onDisconnected resets the tab list, the
boss bars, the toasts, the debug overlay, chat and titles together — which is a
better definition of “HUD state” than any list of fields.
For a 1.21-era reader.
Guiis not the HUD any more. The class that drew the hotbar and hearts isHud; the nameGuiwas reused for the screen and overlay manager that used to be fields onMinecraft. The canonical path isGui.hud. Every render* method onGuiis now an extract* onHud, and gone with them: LayeredDraw, Minecraft.screen, Minecraft.getToastManager, Minecraft.fpsString, Options.hideGui, and DebugScreenOverlay.render along with its two information-gathering methods — the line content moved out into the entry registry.
Where to look
Hud.extractRenderState — all but the last four of the HUD’s elements in one ordered method, and the two
hidden-gated blocks are visible at a glance. Then Hud.extractPlayerHealth
for the most-loved method in the client,
Hud.nextContextualInfoState for the bar arbitration, Gui.extractRenderState
for the four recorded after the screen, DebugScreenEntries for the F3
registry, and ChatComponent for the message list.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Sound: the engine
Verified against Minecraft 26.3 · Part X · a block placed near you: from a packet on the client’s Render thread to an OpenAL source, across four of the five threads that take part and one hop the sound cannot skip.
SoundEngine.play never starts a sound. It resolves the name, picks a variant, tells the subtitle overlay, computes the volume and asks for a channel — and leaves attach buffer, play to be posted as a task on another thread once the buffer is ready.
Even when the decoded audio is already in the cache, a sound always starts
at least one hop to the sound thread after the packet that asked for it, because the engine has
no path that calls Channel.play itself. Preloading removes the decode from
that latency; it does not remove the hop.
This page is the machine: the five threads that take part, the borrowed OpenAL source, the buffer that arrives afterwards, and the arithmetic that decides how loud it is. What decides that a sound should happen at all — and the fact that most world sounds are not named on the wire — is what makes a sound happen.
OpenAL is touched only inside com/mojang/blaze3d/audio, plus
NativeLibrariesBootstrap, which loads the native library. Nothing in
client/sounds makes an AL call itself: it calls into that wrapper, and nearly everything outside calls SoundManager.play and forgets — MusicManager alone reads what it returns.
The cast
| class | what it decides | thread |
|---|---|---|
SoundManager | the loaded sounds.json map, and the public front door | Render thread |
SoundEngine | which instances are playing, how loud, and what to drop | Render thread |
SoundInstance | one playing-or-wanting-to-play sound: position, pitch, looping, attenuation | Render thread |
SoundEngineExecutor | one daemon thread with a task queue in front of it — where a per-source AL call is made | Sound engine |
ChannelAccess | acquiring, configuring and releasing a channel, as tasks | posts to Sound engine |
Library | the OpenAL device, context, listener and channel limits | Render thread opens it |
SoundBufferLibrary | decoded .ogg data, cached per path | the caller’s; the decode on the Download pool |
AbstractDeviceTracker | noticing that the device list or the default device changed | Render thread, querying on IO-Worker |
Five threads, and one the game does not own
The page is mostly about which thread does what, so it is worth having the list before the trace.
| thread | its part in a sound |
|---|---|
| Server | decides a sound happens, computes who is in range, sends packets. Never audio. |
| Render (the client game thread) | receives the packet, builds a SoundInstance, calls SoundManager.play. Also the device’s life: opening and closing the device and context, resetting the Listener, and deleting the cached static buffers. |
| Sound engine | every per-source AL call while the game is running: channel acquisition, parameter setting and release through ChannelAccess, plus the listener transform, which SoundEngine.updateSource posts to the executor directly. The two bulk teardowns are the exception, and they run off it. |
Util.nonCriticalIoPool (the Download- threads) | reads and decodes .ogg files with JOrbisAudioStream into a SoundBuffer, inside SoundBufferLibrary.getCompleteBuffer. |
Util.ioPool (the IO-Worker- threads) | device enumeration — AbstractDeviceTracker.tick dispatches DeviceList.query there, so the refresh of the ALC device list does not stall a frame. A forced refresh still queries on the Render thread. |
And one the game does not own: OpenAL Soft’s own event-callback thread,
which invokes the callback CallbackDeviceTracker installs to notice that a playback device was added, removed or made the default.
Four of those five are on the path of a single sound; Util.ioPool is the odd
one, polling the device list beside the trace rather than inside it. Where
each sits among the rest of the game’s is the
threads.
The Render thread’s two cadences, both set by the client
loop: once per client tick
Minecraft.tick calls MusicManager.tick and then SoundManager.tick, which walks the ticking
sounds, updates positions and volumes, releases finished channels and drains
the delayed queue; once per frame Minecraft.runTick calls
SoundManager.updateSource with the camera.
A block is placed near you
The packet has arrived and ClientLevel.playSeededSound has taken it — that
half of the story is what makes a sound
happen. What this figure shows is
everything after: three objects on the Render thread, two on the sound thread, the buffer library whose decode runs on a download thread, and the boundary every per-source OpenAL call has to cross.
sequenceDiagram
box transparent Render thread
participant CL as ClientLevel
participant SndE as SoundEngine
participant ChanA as ChannelAccess
end
box transparent Sound engine thread
participant SEE as SoundEngineExecutor
participant Library as Library
end
box transparent Download pool
participant SBL as SoundBufferLibrary
end
CL->>SndE: play, via SoundManager
SndE->>SndE: resolve, weigh, tell the listeners, calculateVolume
SndE->>ChanA: createHandle
ChanA->>SEE: execute — the acquire
SEE->>Library: acquireChannel
SEE-->>SndE: join — a handle, or null
SndE->>ChanA: ChannelAccess.<br/>ChannelHandle.execute
ChanA->>SEE: execute — the parameters
SndE->>SBL: getCompleteBuffer
SBL-->>SndE: thenAccept — run by the thread that completes it
SndE->>ChanA: ChannelAccess.<br/>ChannelHandle.execute
ChanA->>SEE: execute — attachStaticBuffer, play
loop per client tick
SndE->>ChanA: scheduleTick, pumping streams
end
Every arrow that crosses into the sound thread’s box is a task queued, not a call made: in this trace ChannelAccess never touches OpenAL itself. Of the two arrows coming back, only the join on ChannelAccess.createHandle’s future waits — the only place in this trace the Render thread does.
Read the three ChanA->>SEE arrows as one shape. The sound thread is reached three times for one sound, and the third of them — attach and play, posted from inside the buffer future’s continuation — is the hop this page opens with. The first two come from the Render thread; the third comes from it only when the buffer was already decoded, and otherwise from the download thread that finished decoding it.
The four beats worth narrating.
The name is resolved, in an order you can hear. The instance’s
Identifier is looked up in the SoundManager registry for a
WeighedSoundEvents, and WeighedSoundEvents.getSound rolls the weighted
choice — following event-to-event redirects — to a concrete Sound. Then, in
this order: the unknown-event and empty-sound cases return early; every registered SoundEventListener is told; the volume is computed; and only then is a zero-volume sound abandoned. So a sound whose
category is muted still produces a subtitle, and a sound with no
sounds.json entry does not. SubtitleOverlay is the only
SoundEventListener in the game, and that ordering is what it is for.
A channel is borrowed, on the sound thread.
ChannelAccess.createHandle posts a task to the SoundEngineExecutor; on
that thread Library generates a new OpenAL source, provided the static or
streaming limit — chosen by Sound.shouldStream — has room. The Render
thread blocks on that future and gets a handle or null; null means the sound
is silently dropped. This is one of exactly two places the Render thread ever waits on the sound thread; the other is teardown, four sections below.
Parameters go first, data arrives later.
ChannelAccess.ChannelHandle.execute posts the
pitch/volume/attenuation/position setup, while
SoundBufferLibrary.getCompleteBuffer returns a future for the decoded
buffer. When that completes, its continuation posts attach buffer, play to the sound thread. That post is the hop this page opens with.
Sound.shouldPreload and SoundEngine.requestPreload remove the decode from
the latency, not the hop.
Streams are pumped by the tick. Long sounds — music, records — are
streamed: Channel.attachBufferStream queues
Channel.QUEUED_BUFFER_COUNT buffers of Channel.BUFFER_DURATION_SECONDS
each, four seconds in all, and ChannelAccess.scheduleTick, posted once per
client tick from SoundEngine.tick, calls Channel.updateStream on each to
refill. The same pass releases channels whose source reports stopped.
The channel limits are counters, not pools
Library asks the device how many mono sources it offers, falling back to
Library.DEFAULT_CHANNEL_COUNT — thirty. The streaming limit is the square
root of that, clamped between two and eight; the static limit is a clamped
remainder, floored at eight and capped at 255, so it is not simply “the
rest”. Sources are generated on acquire and deleted on release: one OpenAL
source per playing sound, and when a limit is reached new sounds are dropped
rather than queued. The game does not steal channels by priority.
Nor does muting free one. SoundEngine.refreshCategoryVolume pushes the new
volume to every playing channel of that category and stops nothing, so a
looping sound muted to zero holds its OpenAL source until it ends on its own.
Muting suppresses new allocations; it does not reclaim old ones. The
OpenAL source itself, though, goes back the moment the channel reports
stopped: ChannelAccess.scheduleTick releases it with no lifetime gate at
all. SoundEngine.MIN_SOURCE_LIFETIME holds something else — the engine’s bookkeeping entry for the instance, for at least twenty ticks from the play, so a sound shorter than a second keeps its entry for the rest of that second after its source has gone.
SoundEngine.queuedSounds is the one of its fields worth naming here, because
it is what the trace has not shown: a sound can be delayed rather than played,
and SoundEngine.tick drains that queue once per client tick. Three things put a sound in it — a distance delay, which is what makes a sound happen, the End flash’s fixed thirty-tick delay, and a manual loop, three paragraphs below. Beside it SoundEngine.instanceToChannel,
SoundEngine.instanceBySource, SoundEngine.tickingSounds,
SoundEngine.gainBySource and SoundEngine.soundBuffers are the bookkeeping.
SoundEngine.play returns a SoundEngine.PlayResult — started, started
silently, or not started — and SoundManager.play passes it through;
MusicManager is the only caller that reads it.
Volume, looping, and the attenuation everyone explains wrongly
SoundEngine.calculateVolume multiplies the instance’s own volume, the
options volume (Options.getFinalSoundSourceVolume, itself category times
master) and the runtime gain in SoundEngine.gainBySource. The third exists
so that MusicManager can fade a category without touching the player’s
slider — which is why the music slider and the music fade are two different
numbers.
A computed volume of zero is abandoned unless it is music:
SoundEngine.play drops it only when the instance does not say
SoundInstance.canStartSilent and the category is not
SoundSource.MUSIC. Music always starts, silently if need be: under a music or master slider at zero a track plays unheard, and MusicManager withholds the now-playing toast until the slider is raised.
Looping happens three different ways. Static sounds loop in OpenAL, with
Channel.setLooping. Streamed sounds loop by wrapping the decoder in a
LoopingAudioStream, since the source only ever holds a few seconds. And a
looping instance with a delay is looped manually —
SoundEngine.shouldLoopManually — by re-queueing it into
SoundEngine.queuedSounds when its channel stops.
The third thing in this section is attenuation, and the obvious explanation of
it is wrong. UI sounds do not attenuate because of their attenuation, not
their relativity: SimpleSoundInstance.forUI sets both
SoundInstance.Attenuation.NONE and the relative flag, and it is the former
that makes the engine call Channel.disableAttenuation. A relative sound
offset from the listener would still fall off.
The instance family, and the decode stack
SoundInstance lives in client/resources/sounds and carries event, source,
volume, pitch, position, looping, relative and attenuation.
SimpleSoundInstance is a one-shot at a point; the TickableSoundInstance subclasses — AbstractTickableSoundInstance, EntityBoundSoundInstance following its entity, minecarts, elytra, bees, ambient loops — re-evaluate themselves every tick.
Below the engine, com/mojang/blaze3d/audio is the OpenAL wrapper: Library
(device, context, listener, channel limits), Channel (one source),
SoundBuffer (one buffer), Listener (the ear, set from a
ListenerTransform), DeviceList, and the device-tracker family
(AbstractDeviceTracker, CallbackDeviceTracker) that notices headphones
being unplugged.
The decode stack is three interfaces deep: AudioStream, then
FiniteAudioStream, then FloatSampleSource, with ChunkedSampleByteBuf
assembling the samples and JOrbisAudioStream — JOrbis, a Java Vorbis
decoder — as the one real implementation. LoopingAudioStream wraps any of
them.
The sound thread is not the mixer, and the device is not its
Two things about that thread are assumed the wrong way round often enough to be worth stating flatly.
It does not mix. SoundEngineExecutor does nothing but run tasks; OpenAL, the native library, does the mixing on threads of its own that no Java code ever sees. The Java thread exists so that per-source AL calls are serialised — the tasks include decoding the next seconds of a stream — and almost all of them go through it. The exceptions are the two bulk teardowns, ChannelAccess.clear and Library.cleanup, which release handles directly on the Render thread: the first after the sound thread has been joined, the second after SoundEngine.stopAll has emptied every channel and started a fresh thread with nothing queued — or, in a crash, from SoundEngine.emergencyShutdown with the sound thread still running.
It does not own the device. Opening and closing the device and context, resetting the listener and deleting the cached static buffers all happen on the Render thread, and an orderly teardown (SoundEngine.destroy) releases the channels only after SoundEngineExecutor.shutDown has joined the sound thread. That join is the second of the two places the Render thread blocks on the sound thread, and SoundEngine.stopAll is where it happens; the channel acquisition four sections above is the first.
What a device has to offer before any of this starts is exactly three things,
and Library.init throws rather than degrade if one is missing: an ALC of 1.1
or newer, the AL_EXT_source_distance_model extension and the
AL_EXT_LINEAR_DISTANCE extension. Everything beyond those it can do without —
HRTF is taken only when the device offers ALC_SOFT_HRTF and
Options.directionalAudio is on, and the lost-device check only when the device has ALC_EXT_disconnect; without it the engine still reloads when the device list shows the current device gone, the default moved or the chosen device back.
Questions players ask
Why does sound cut out when I plug in headphones? Because reload is
destroy-and-rebuild, and it arrives from three different doors. The resource
reload arrives as
SoundManager.apply, which ends by reloading the engine;
SoundManager.reload is the options path, taken when the audio device is
changed; and SoundEngine.tick reloads itself when the device is lost, or when the tracker’s list shows the current device gone, the default moved or the chosen device back. All three tear the OpenAL context down in
Library.cleanup and call SoundEngine.loadLibrary again.
Why is there no sound for the first few frames of a world?
SoundEngine.updateSource posts a ListenerTransform — position, forward,
up — from the Camera every frame, and it no-ops until
Camera.isInitialized. The same is true again after
GameRenderer.resetData. And the ear is always one frame stale:
Minecraft.runTick posts the transform before it renders, and the camera
is only advanced inside the frame, so the ear is where the eye was last
frame. At 60 fps nobody hears it — but it is worth knowing before blaming
OpenAL.
Does pausing stop everything? No: SoundManager.pauseAllExcept leaves
SoundSource.MUSIC and SoundSource.UI running, and
SoundEngine.tickMusicWhenPaused is the pause-menu tick.
Where to look
SoundEngine.play — the whole resolution-and-dispatch order is one method,
and the early returns in it are audible. ChannelAccess and
ChannelAccess.ChannelHandle for how a per-source call becomes a task, and
SoundEngineExecutor for the thread it becomes a task on.
SoundEngine.calculateVolume for the three factors, Library.init for the
limits and the device requirements, SoundBufferLibrary.getCompleteBuffer
for the decode, and SoundEngine.tick for the once-a-tick sweep.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
What makes a sound happen
Verified against Minecraft 26.3 · Part X · you break a block and you hear it: three doors a sound can come through, and only one of them says what the sound is.
Watch someone place a block and the server sends ClientboundSoundPacket,
naming the sound. Watch them break the same block and it sends nothing of the kind: Block.playerWillDestroy fires a level event through Block.spawnDestroyByEntityParticles, and
ClientboundLevelEventPacket carries an int and a block-state id. The client
decides for itself what that int means — for a break,
SoundType.getBreakSound off the block’s own SoundType, the five-sound
group (break, step, place, hit, fall) every BlockBehaviour.Properties
carries — and plays the result locally. So the two
halves of one interaction reach you by different mechanisms, and the second
one is not a sound at all until your client makes it one.
Then there is the third door, and it is the absence of a packet rather than a packet: some sounds never cross the wire at all. Two quite different things come through it. Biome loops, the cave mood, music, the underwater hum are decided by your client and nobody else’s. And so, more surprisingly, are your own place and break — neither of the calls above is sent to the player who caused the sound, because that player’s client has already played it locally. Who hears it below is the one rule that arranges both.
This page is the content model and those three doors, and it takes them first: the table below is what the page is for, and the cast comes after it, because half of the eight classes only make sense once you know which door they stand in. The machine that turns any of them into an OpenAL source is the sound engine.
The three doors
flowchart TD
SERVER["something happens on the server"]
NAMED["door 1 — Level.playSound names a SoundEvent"]
NOWIRE["door 3 — nothing crosses the wire"]
EVENT["door 2 — Level.levelEvent sends an int and a block-state id"]
P1["ClientboundSoundPacket or ClientboundSoundEntityPacket"]
P2["ClientboundLevelEventPacket"]
LEH["LevelEventHandler reads the int against this client's block data"]
LOCAL["ClientLevel.playSeededSound — the excluded entity is you, so the test passes"]
MINE["your own place, through the shared Level.playSound"]
MINEB["your own break, a level event run locally"]
AMB["BiomeAmbientSoundsHandler, MusicManager, the underwater and bubble-column handlers"]
SM["SoundManager.play"]
SERVER --> NAMED --> P1 --> LOCAL
NOWIRE --> MINE --> LOCAL
NOWIRE --> AMB --> SM
NOWIRE --> MINEB --> LEH
LOCAL --> SM
SERVER --> EVENT --> P2 --> LEH --> SM
Doors one and three meet, which is the point of the picture: a packet and your own placing hand both arrive at ClientLevel.playSeededSound, and both pass its one test because the excluded entity is you. Your own break runs door two’s handler locally instead, and door two never names a sound on the wire at all.
The three columns are how to reason about the wire, and the third is the one the usual summary leaves out entirely.
| a named sound | a level event | nothing on the wire | |
|---|---|---|---|
| what crosses | a SoundEvent holder, a position in eighths of a block, a seed | an int and a block-state id | nothing |
| who chooses the sound | the server | this client, from its own resource pack and block data | this client |
| can it name a sound in no registry | yes — SoundEvent.STREAM_CODEC sends either a registry id or an id plus a range | only a jukebox song’s, whose entry may carry one inline | yes, when the data behind it carries one inline: the synced ambience and music attributes, a goat horn’s instrument, an item’s consume sound |
| examples | a block placed, /playsound, a mob’s voice | a block broken, a dispenser, fire extinguished, a ghast warning, the wither spawn, the dragon’s death | your own place and break; biome loops, cave mood, music, underwater, bubble columns |
That third row is a genuine hole in the usual summary. Data packs cannot
register sound events — Registries.SOUND_EVENT is a static registry —
but a packet may carry an inline SoundEvent, so a server can name a
sound that is in no registry at all. The two statements are both true and are
usually run together into a false one.
The other clientbound members of the family are
ClientboundSoundEntityPacket, which follows a moving entity,
ClientboundStopSoundPacket, which /stopsound sends, and
ClientboundExplodePacket, which carries its own sound alongside everything
else an explosion needs. There is no serverbound sound packet anywhere: the server infers what you did from
other packets and tells everyone else about the sound.
The cast
| class | what it decides | thread |
|---|---|---|
SoundEvent | a name and an optional fixed range — never a file | both sides |
SoundEvents | the static registry of every event the game defines | both sides |
SoundSource | which volume slider applies | both sides |
SoundEventRegistration | what one sounds.json entry says, and whether it replaces or appends | worker pool |
WeighedSoundEvents | the weighted list a name resolves to, and the redirects in it | worker pool, then Render thread |
LevelEventHandler | what an int from ClientboundLevelEventPacket means | Render thread |
BiomeAmbientSoundsHandler | the loop, the random additions and the cave mood | Render thread |
MusicManager | which track, how often, and the fade that is not the slider | Render thread |
What a sound is, as data
SoundEvent — in net/minecraft/sounds, and therefore shared — is a record
of an Identifier and an optional fixed range. SoundEvents is the static registry of every one the game defines. It is a name, not
a file.
The file comes from sounds.json, one per namespace in every resource pack,
which SoundEventRegistrationSerializer parses into a
SoundEventRegistration per event name: a weighted list of
Sound entries — a file, or a redirect to another event, per the
Sound.Type enum — each with volume, pitch, weight, attenuation distance,
and whether to stream rather than load whole. SoundManager owns the
loaded form, a map of Identifier to WeighedSoundEvents, rebuilt on every
resource reload.
Packs merge rather than replace, as tags, languages and atlas sources do among the lists the resource system merges instead of letting the top pack win, and, as a tag can, an entry may say otherwise.
SoundEventRegistration carries a replace flag; without it a higher pack’s
entries are appended to the lower pack’s list, so a pack that adds one
variant gets a mix rather than an override. A redirect entry multiplies
volume and pitch through and ORs the streaming flag.
And there are two kinds of silence, which is worth knowing because only one of
them is a mistake. The identifier
SoundManager.INTENTIONALLY_EMPTY_SOUND_LOCATION is short-circuited by name in
AbstractSoundInstance.getOrResolve before the registry is consulted at all, so
anything asking for it is silenced with no log warning. An event that
simply does not resolve is the other kind, and it logs — including a pack that
empties an event’s list, which gets the warning rather than the quiet.
One development flag makes sounds visible: SharedConstants.DEBUG_SUBTITLES gives every sound that plays a subtitle, its path, whether or not its event declares one. Neither kind of silence gets one, because neither reaches the listeners.
SoundSource is the volume category and each one is an options slider:
SoundSource.MASTER, SoundSource.MUSIC, SoundSource.RECORDS,
SoundSource.WEATHER, SoundSource.BLOCKS, SoundSource.HOSTILE,
SoundSource.NEUTRAL, SoundSource.PLAYERS, SoundSource.AMBIENT,
SoundSource.VOICE, SoundSource.UI. Two of those carry more than the
options screen says: SoundSource.RECORDS, Jukebox/Note Blocks, also carries the goat horn, and SoundSource.WEATHER carries thunder and the End flash as well as rain. Neither is SoundSource.AMBIENT.
Who hears it
ServerLevel.playSeededSound asks SoundEvent.getRange for the audible
radius — a fixed range if the event declares one, otherwise sixteen blocks
scaled up by volumes above one — and PlayerList.broadcast sends the packet
to every player in that dimension within range, skipping the excluded
player. The seed travels in the packet so that every client picks the same
variant and samples the same pitch multiplier off the chosen Sound, which is
why a sound that is one of eight variants sounds the same to two players
standing together.
And the excluded player is not left out; they are served first. The two
sides read that one word oppositely. ServerLevel.playSeededSound broadcasts
to everyone in range but the excluded entity; ClientLevel.playSeededSound —
the same method name, the client’s override, in both its positional and its
entity-bound form — plays a sound only when the excluded entity is the
local player.
That inversion is what makes the client override usable for two jobs at once,
and it is worth being explicit about which is which. When a packet arrives,
ClientPacketListener.handleSoundEvent hands the override the local player
as the excluded entity, so the test passes and a bystander hears the packet
normally. When shared block or item code calls Level.playSound while running
on your own client, the excluded entity is you for the same reason it was you
on the server — and the test passes again, locally. So the one method plays the sounds sent to you and the sounds never sent to anyone, and each machine hears exactly one copy: yours locally, theirs by packet. That is how your own place comes through the third door, with every other sound shared code plays with you excluded. Your break comes through it another way, and never reaches this method: the same shared code fires a level event on your client and on the server, ClientLevel.levelEvent plays it with no test at all, and ServerLevel.levelEvent sends it to everyone in range but the player who broke the block.
Two qualifications keep it from being a law. The rule needs an exclusion to
read: Player.playServerSideSound, which plays the six attack sounds,
excludes nobody, so your own critical hit does travel the whole way out and
back. And your local copy is genuinely a different sound from the one your
neighbour hears — your client drew its own seed (from Level.soundSeedGenerator for a place or a door, from the level’s own random for a break, which carries no seed on the wire, so every client that hears it rolls its own) rather than reading one off a packet, so the variant and the pitch are rolled twice (block interaction
follows a door through both rolls).
The position is quantised on the way:
ClientboundSoundPacket.LOCATION_ACCURACY is eight, so the wire carries
three ints in eighths of a block.
And sound has a speed, for the few callers that ask for it.
ClientLevel.playLocalSound takes a distance-delay flag, and when it is set
and the source is more than ten blocks off the sound is deferred by its
distance over a fixed rate, through SoundManager.playDelayed into
SoundEngine.queuedSounds. Firework explosions and a handful of level events
set it. Thunder, the sound everyone assumes is the reason it exists, does not:
LightningBolt passes the flag as false and the crack is instant.
LocalPlayer.playSound goes
the other way and calls ClientLevel.playLocalSound directly, skipping even
the exclusion check.
Music and ambience are environment attributes
BiomeSpecialEffects carries block tint only. Music and the ambient loop, additions and mood are carried by EnvironmentAttributes (see environment attributes and timelines): EnvironmentAttributes.BACKGROUND_MUSIC and EnvironmentAttributes.MUSIC_VOLUME for the music, EnvironmentAttributes.AMBIENT_SOUNDS for the other three together, beside EnvironmentAttributes.FIREFLY_BUSH_SOUNDS, a gate on the firefly bush’s own sound — all syncable, all resolved
through the same dimension-then-biome-then-timeline-then-weather layer stack
as fog and sky colour.
Minecraft.getSituationalMusic reads EnvironmentAttributes.BACKGROUND_MUSIC
off the camera’s attribute probe and asks BackgroundMusic.select for the
creative or underwater variant; the End boss fight overrides it directly.
Minecraft.getMusicVolume reads EnvironmentAttributes.MUSIC_VOLUME the
same way, which is how a biome dims its own music without touching the
slider.
BiomeAmbientSoundsHandler reads EnvironmentAttributes.AMBIENT_SOUNDS from
Level.environmentAttributes and drives three things from it: a cross-faded
loop, random additions on a per-tick chance (AmbientAdditionsSettings), and
the cave “mood” that accumulates in darkness (AmbientMoodSettings).
UnderwaterAmbientSoundHandler and BubbleColumnAmbientSoundHandler are the
two that remain plain client-side handlers with no attribute behind them.
MusicManager owns the rest: a MusicManager.MusicFrequency setting that caps the gap between tracks, a fade that drives
SoundManager.updateCategoryVolume rather than the player’s slider (the third
of the engine’s three volume
factors),
and the now-playing toast — a NowPlayingToast shown or withheld on the
SoundEngine.PlayResult the engine returned, and suppressed again by the
pause screen and by MusicToastDisplayState.
The remaining callers are worth naming because they are the ones that are neither the world nor the wire: the ambient handlers in client/resources/sounds for loops that exist only on the client, the GUI’s clicks and toast chimes (AbstractWidget.playButtonClickSound, ToastManager), and SoundPreviewHandler, which previews a representative sound for most categories while a volume slider is dragged outside a world.
Where to look
LevelEventHandler — the whole second door is two switches in one class, LevelEventHandler.levelEvent and LevelEventHandler.globalLevelEvent, and reading them is the fastest way to see how much of the game’s audio is not named on the wire.
ServerLevel.playSeededSound and PlayerList.broadcast for who is told;
ClientLevel.playSeededSound for the local-player branch that closes the
loop. SoundEventRegistration and WeighedSoundEvents for the pack model,
and BiomeAmbientSoundsHandler for the three ambience mechanisms in one
class.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Debugging the running game
Verified against Minecraft 26.3 · Part X · a villager’s brain drawn over its head: one subscription mechanism, sixteen instances, all of them in the jar you downloaded and fifteen of them unreachable without a JVM flag.
Every one of these sixteen debug subscriptions is compiled into the shipped client and the shipped dedicated server. Every subscription is in the registry, every packet is in the protocol, every producer call site is there. Nothing is stripped. The client simply never asks — fifteen of the sixteen are behind a JVM system property read at startup, and the server has to agree besides. The idle cost is small but it is not nothing: the producers check for a subscriber before they work, and the server sweeps every online player’s permissions once a tick whether or not anyone has asked for anything.
That is the pattern the page is about: a registry of subscription kinds, a per-level engine that sleeps until somebody asks, a poll-and-diff sender, and the renderers — thirteen of the twenty-five — that turn the results into floating text and boxes. It is only half a client system — the machinery ships on the dedicated server — but the client is the only thing that ever asks and the only thing that draws, and the trace ends in a renderer.
The cast
| class | what it decides | thread |
|---|---|---|
DebugSubscription | what one kind of debug value is: a stream codec, and an expiry | both sides |
DebugSubscriptions | the sixteen kinds | both sides |
DebugValueSource | which objects can answer which subscriptions | Server thread |
ServerDebugSubscribers | who is subscribed, rebuilt every tick, and the permission rule | Server thread |
LevelDebugSynchronizers | per level, a source synchronizer for each of the fifteen subscriptions with a value codec, the event-driven point-of-interest and village-section synchronizers, and the sleep flag | Server thread |
TrackingDebugSynchronizer | the engine: registration, the diff, and the tracking filter | Server thread |
ClientDebugSubscriber | what to ask for, and the maps holding what came back | Render thread |
DebugRenderer | the renderer list, rebuilt when the enabled entries change | Render thread |
The idea
A DebugSubscription is a registry object — held in
BuiltInRegistries.DEBUG_SUBSCRIPTION under Registries.DEBUG_SUBSCRIPTION
— carrying exactly two things: a nullable stream codec for its value type,
and an expiry in ticks, which is DebugSubscription.DOES_NOT_EXPIRE for
most. Its payload wrappers are the records DebugSubscription.Update — a
subscription plus an optional value, so absence is expressible — and
DebugSubscription.Event, a subscription plus a value. Both are serialised
by dispatching on the registry id onto the subscription’s own codec.
That is the whole abstraction, and it replaces what used to be a fixed packet type per kind of information.
DebugValueSource is the supply side, implemented by Entity, Mob, Bee,
Breeze, BlockEntity, BeehiveBlockEntity and LevelChunk;
DebugValueSource.registerDebugValues hands back one
DebugValueSource.ValueGetter per subscription the object can answer. On the
client, ClientDebugSubscriber keeps what came back, keyed by chunk
position, block position or entity UUID, plus a list of expiring events, and
ClientDebugSubscriber.createDebugValueAccess hands renderers a read-only
DebugValueAccess view. DebugRenderer is a plain list of DebugRenderer.SimpleDebugRenderers,
rebuilt by DebugRenderer.refreshRendererList. Neither the synchronizer that
produced the value nor the renderer that consumes it draws anything: the
renderer appends, which is the next section.
A renderer does not draw a gizmo; it appends one
The renderers at the end of this trace emit through Gizmos, a debug-drawing
API in the game-engine sense: the immediate-mode draw me a box in the world
for one frame facility most engines have and Minecraft did not. Gizmos is a
static façade over a thread-local GizmoCollector, and Gizmos.addGizmo
throws when no collector is installed on the calling thread — so where you
may draw is decided by where you are running, not by what you are allowed to
see. The shapes are small records — CuboidGizmo, LineGizmo, ArrowGizmo,
CircleGizmo, RectGizmo, PointGizmo, TextGizmo — a GizmoStyle is a
stroke and a fill, and the GizmoProperties handle a call returns can pin the
shape on top, keep it for a number of milliseconds, or fade it.
Five places in the game install a collector; four of them keep what they are given, and those four are the table. The fifth is GameTestServer, which installs GizmoCollector.NOOP, so code that draws does not throw in a headless test run. The fourth row is the one worth knowing, though nothing the game ships draws into it.
| collector | installed by | what it collects |
|---|---|---|
| per tick | Minecraft.collectPerTickGizmos | anything the client tick draws, cleared each tick |
| per frame, during extraction | LevelExtractor.collectPerFrameMainThreadGizmos | the debug renderers of this page, inside the extract pass |
| per frame, during rendering | LevelRenderer.collectPerFrameRenderThreadGizmos | what the world renderer itself wants outlined |
| the integrated server | IntegratedServer, around its whole packet-and-tick step | server tick code drawing into a singleplayer world, published for the client to drain |
So server-side game logic can draw, and only in singleplayer: a dedicated server installs no collector at all, so the identical call there would throw. The two renderers below that want server state are singleplayer-only as well: they reach for the singleplayer server directly rather than asking over the wire, and no subscription carries what they draw.
The sixteen instances
Fourteen rows, sixteen subscriptions: two of the rows carry a matched pair.
| subscription | carries | fed by |
|---|---|---|
DebugSubscriptions.BRAINS | DebugBrainDump | Mob.registerDebugValues |
DebugSubscriptions.GOAL_SELECTORS | DebugGoalInfo | Mob.registerDebugValues |
DebugSubscriptions.ENTITY_PATHS | DebugPathInfo | Mob.registerDebugValues, from the navigator’s current Path |
DebugSubscriptions.BEES / DebugSubscriptions.BEE_HIVES | DebugBeeInfo / DebugHiveInfo | Bee and BeehiveBlockEntity |
DebugSubscriptions.BREEZES | DebugBreezeInfo | Breeze |
DebugSubscriptions.POIS | DebugPoiInfo | TrackingDebugSynchronizer.PoiSynchronizer, event-driven |
DebugSubscriptions.VILLAGE_SECTIONS | nothing but presence | TrackingDebugSynchronizer.VillageSectionSynchronizer |
DebugSubscriptions.RAIDS / DebugSubscriptions.STRUCTURES | positions / DebugStructureInfo | LevelChunk.registerDebugValues |
DebugSubscriptions.GAME_EVENT_LISTENERS | DebugGameEventListenerInfo | the listener registry |
DebugSubscriptions.GAME_EVENTS | DebugGameEventInfo | the dispatcher — an event, expiring after 60 ticks |
DebugSubscriptions.NEIGHBOR_UPDATES | a position | a listener installed on the neighbour updater — an event, 200 ticks |
DebugSubscriptions.ENTITY_BLOCK_INTERSECTIONS | DebugEntityBlockIntersection | Entity, pushed directly, 100 ticks |
DebugSubscriptions.REDSTONE_WIRE_ORIENTATIONS | an Orientation | the experimental wire evaluator, 200 ticks |
DebugSubscriptions.DEDICATED_SERVER_TICK_TIME | no value at all | see the sample path below |
Four of the sixteen declare an expiry, and “expires after n ticks” means two
different things among them. DebugSubscriptions.GAME_EVENTS and
DebugSubscriptions.NEIGHBOR_UPDATES are events, and the number is how long
the event stays on screen. DebugSubscriptions.ENTITY_BLOCK_INTERSECTIONS and
DebugSubscriptions.REDSTONE_WIRE_ORIENTATIONS are values pushed directly, and
the number is a time-to-live on a stored value. Only the client purges, and
only for those four.
One instance traced: a villager’s brain
sequenceDiagram
box transparent the client
participant CDS as ClientDebug<br/>Subscriber
participant BDR as BrainDebugRenderer
end
box transparent the server
participant SGPL as ServerGamePacket<br/>ListenerImpl
participant SDS as ServerDebug<br/>Subscribers
participant LDS as LevelDebug<br/>Synchronizers
participant TDSS as TrackingDebug<br/>Synchronizer.<br/>SourceSynchronizer
end
CDS->>CDS: requestedSubscriptions — the JVM has the brain flag
CDS->>SGPL: ServerboundDebugSubscriptionRequestPacket, BRAINS
SGPL->>SGPL: handleDebug<br/>SubscriptionRequest — stored, unhonoured
Note over SGPL,TDSS: the end of the same server tick
SDS->>SDS: tick — op, or the owner of an IDE world?
Note over SGPL,TDSS: the next server tick
LDS->>LDS: tick — subscribers exist, so wake up
LDS->>TDSS: registerChunk and registerEntity for everything already tracked
TDSS->>TDSS: the Mob offers a DebugValueSource.<br/>ValueGetter
loop each server tick
TDSS->>TDSS: pollAndSendUpdates — takeBrainDump, compare with the last sent
TDSS->>CDS: ClientboundDebugEntityValuePacket — only if it differs
end
CDS->>CDS: updateEntity — stored under the villager's UUID
Note over CDS,BDR: the next client frame
BDR->>BDR: emitGizmos — reads through DebugValueAccess
BDR->>BDR: Gizmos.<br/>billboardTextOverMob — appended now, drawn later
Two sides and, from a sleeping level, one tick of lag before anything is sent: the request is stored at the head of a server tick and the subscriber set read at its end, and the synchronizers wake, and first send, in the next. The packet in the
loop reaches ClientDebugSubscriber through ClientPacketListener like any
other, which is why no listener lane is drawn.
The figure’s own names, in its order:
ClientDebugSubscriber.requestedSubscriptions is what the JVM flag filled;
ServerGamePacketListenerImpl.handleDebugSubscriptionRequest stores the set
on the ServerPlayer; LevelDebugSynchronizers calls TrackingDebugSynchronizer.SourceSynchronizer.registerChunk and TrackingDebugSynchronizer.SourceSynchronizer.registerEntity for everything already tracked; TrackingDebugSynchronizer.SourceSynchronizer.pollAndSendUpdates is the per-tick compare, and for a villager the value it asks for is a brain dump;
ClientDebugSubscriber.updateEntity files the answer under the entity’s
uuid; and BrainDebugRenderer reads it back next frame.
The engine is the synchronizers’ part of that, and it has three properties worth naming. Nothing exists until somebody asks: the level’s synchronizers start asleep, and the first non-empty subscriber set wakes them and retroactively registers every ready chunk and every tracked entity. A polled value is sent again only when it changes: each value source keeps the last value it sent and compares, and a player who starts tracking is sent that stored value once. The point-of-interest synchronizers and the pushed kinds send on each event instead. And nothing reaches a player who cannot see it: sending is filtered by subscription and by whether that player is tracking the chunk or entity. When the last subscriber goes away the value sources are cleared and the level goes back to sleep.
The three cadences: ClientDebugSubscriber.tick runs from
ClientPacketListener.tick, once per client tick, and sends only when the
wanted set differs from the last one sent.
ServerDebugSubscribers.tick runs from MinecraftServer.tickChildren
after the levels have ticked, while each LevelDebugSynchronizers.tick
runs inside its level’s tick — so every level acts on the previous tick’s
subscriber snapshot, a built-in one-tick lag. And DebugRenderer.emitGizmos
runs inside LevelExtractor.extract — the snapshot step of the frame — after entities, block entities, particles, sky and clouds,
fetching one DebugValueAccess for the whole pass.
The exceptions
Every pattern page’s real content.
Two gates, and the second is not a flag. Fifteen of the sixteen kinds are
behind SharedConstants.DEBUG_ENABLED and one of a set of constants beside it —
SharedConstants.DEBUG_BRAIN, SharedConstants.DEBUG_POI,
SharedConstants.DEBUG_BEES and their siblings, thirteen for the fifteen kinds, all read from JVM system properties at startup, so turning one on means launching the game
differently rather than pressing anything. The only subscription an F3 key can
reach is the dedicated server’s tick time, through the FPS charts. And
the server still has to agree: ServerPlayer.debugSubscriptions returns
nothing unless ServerDebugSubscribers.hasRequiredPermissions passes, which
means op on the player list, or the owner of a singleplayer world run from an
IDE. On a normal singleplayer world that means cheats must be on.
Producers check before they work. Path finding only records its open and
closed node sets when somebody wants paths; entities only collect block
intersections when somebody wants them; the neighbour updater’s debug
listener is only installed while someone is subscribed. There are two gates
of that name and the producers do not agree on which to use: path finding and
block intersections ask ServerDebugSubscribers.hasAnySubscriberFor, the live
map, while the neighbour updater asks
LevelDebugSynchronizers.hasAnySubscriberFor, which reads the level’s
snapshot from the previous tick. The change detection, by contrast, is
record equality: a
brain dump is rebuilt every tick per villager and compared with the last one
sent — so the saving is in bandwidth, not in server time.
The other twelve renderers do not use this system at all. The chunk debug
renderer reaches directly into Minecraft.getSingleplayerServer and shows
nothing in multiplayer; the entity hitbox renderer reaches for it too, but only to check the server has the entity, for its flag-gated server hitbox; the ordinary hitbox it draws itself, switched by an F3 entry rather than a flag, as entity rendering says. And a
whole family of them — chunk borders, light, collision boxes, height maps,
the section octree — are purely client-side views that need no server.
One flag combination under-delivers, and says so. The POI renderer’s
ticket-holder rows are behind an explicit SharedConstants.DEBUG_BRAIN test,
so running with the POI flag alone gives POI boxes with their own two labels
and none of the brain ones. The bee flag avoids needing the test at all by
explicitly also requesting goal selectors.
Subscriptions survive a dimension change and a death, but not a
reconnect. A dimension change keeps the same ServerPlayer, so the set is
simply never touched; a respawn builds a new one and ServerPlayer.restoreFrom
copies the requested set across. A fresh login starts empty, and the client
re-sends on its next tick because ClientDebugSubscriber was cleared at
login.
The other query, which is one transaction deep
Not everything the client asks the server for is a subscription. Press the
copy-recreate-command key — Options.keyDebugCopyRecreateCommand, an ordinary
rebindable mapping — while looking at a block or an entity and the client
builds a /setblock or /summon for it and puts it on the clipboard. Whether
the NBT in that command is yours or the server’s is decided by two things.
Holding shift takes the client’s own copy, straight out of the block entity or
the entity in front of you. Not holding it goes to the server, through
DebugQueryHandler: ServerboundBlockEntityTagQueryPacket or
ServerboundEntityTagQueryPacket with a transaction id, a callback parked
against that id, and the command assembled when the reply comes back. The
handler holds one callback and one id, so a second query cancels the first
by overwriting it, and a reply whose id does not match is dropped on the
floor. Including the NBT at all needs the gamemaster permission, and the whole
key is dead while the server has reduced debug info on.
The sample path, which shares only a subscription
The performance charts are a separate and much simpler system, and what they have in common with everything above is the subscriber side: one of the sixteen subscriptions gates the remote half of them, asked for in the same request and granted by the same permission rule. There is no shared engine, no
diff and no tracking filter. A SampleLogger takes a vector of longs; a tick
logs its parts as it goes and a final call flushes the whole vector. There are two implementations
and the difference is the whole story: LocalSampleLogger is the storage
— a SampleStorage ring buffer the charts read directly — while
RemoteSampleLogger stores nothing and broadcasts a
ClientboundDebugSamplePacket if anyone is subscribed.
So a dedicated server measures its tick with a remote logger and sends, while
IntegratedServer.getTickTimeLogger hands back the client’s own local
logger and reports logging as unconditionally enabled — singleplayer TPS
never touches the network. Client-side, DebugScreenOverlay owns four local
loggers with four different feeders: the frame time from the loop, the tick
time from either of the two paths above, the ping from PingDebugMonitor
(which sends its own ping requests, and only while the network charts are
shown), and the bandwidth from a BandwidthDebugMonitor that counts bytes on
the Netty thread and is drained by Connection.tick.
Six packets carry the subscriptions and the samples between them:
ServerboundDebugSubscriptionRequestPacket outbound, and
ClientboundDebugChunkValuePacket, ClientboundDebugBlockValuePacket,
ClientboundDebugEntityValuePacket, ClientboundDebugEventPacket and
ClientboundDebugSamplePacket inbound — for a system that used to have one
packet per subject. The two tag-query packets of the section before are not
among them, and are not part of this machinery at all.
For a 1.21-era reader. The fixed set of debug packets is gone. Instead of one packet type per kind of debug information there is one
DebugSubscriptionregistry and three generic value packets that dispatch on the registry id. The debug screen is a different system again — the F3 entry registry described in the HUD — and it is not a light touch: the F3 entries decide whether eleven of the twenty-five renderers exist at all when the list is rebuilt, and the FPS charts gate the tick-time subscription.
Where to look
DebugSubscriptions for the catalogue and DebugSubscription for how little
a subscription is. TrackingDebugSynchronizer for the engine — the tracking diff and the equality check are in that one class, and the wake-up’s back-fill is LevelDebugSynchronizers’.
LevelDebugSynchronizers.tick for the sleep flag, ClientDebugSubscriber
for both ends of the client’s half, and DebugRenderer.refreshRendererList
for which renderers exist and why.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
XI · Rendering
Verified against Minecraft 26.3 · Part XI · One thread, a hundred-odd times a second, turning a world nobody can see into a picture — and two layers of machinery underneath that never touch the world at all.
The renderer is not allowed to look at the world. A frame is one call to
Minecraft.renderFrame, on the same thread that ticked the
world a moment earlier, and it runs twice over: once to copy the live game
into a pile of value objects, and once to draw those objects with the live
game held at arm’s length. Everything in this part is either that wall, or
something feeding it, or something on the far side of it working from a copy.
And the claim the part makes is that nothing you see on screen is the
world; it is a copy of the world, and every seam a player notices is two
copies disagreeing. A mob pins in place under /tick freeze while the item
in your hand keeps swaying, because each was copied at a partial tick of its own. A
chest’s lid stops and the chest in your hand does not, for the same reason
one step further out. And terrain fills in outward as you fly because the
copy that decides what is visible can only reach as far as the copies that
already exist — which is the same disagreement again, now between the world
and the meshes that stand in for it.
Besides the renderer’s own per-tick half, three things escape the frame, and each of them is a page: particles are stepped from the tick, most sections are meshed on a background pool, and the atlases are built by a resource reload and animated before each frame.
It is also the largest thing on the client. client/renderer,
client/model, client/particle, com/mojang/blaze3d and
com/mojang/renderpearl together come to
1,291 files and 98,283 lines — counted the way the atlas counts a part, leaving out the hatched packages — against 358 files and 48,834 lines for net/minecraft/server counted the same way. net/minecraft/server has a little over a quarter as many files as the client’s renderer, and about half its lines.
The shape of the part
Part XI is a substrate under a pipeline. Two of its pages — the window
and Blaze3D — are what the renderer stands on: neither has a trace through
the world, and Blaze3D is cited far more than it cites. The rest is a
pipeline. The frame opens the part ahead of the substrate it
stands on, because it is the shortest way to see the whole shape at once and
because a reader who has watched one frame end to end has a reason to care
what a GpuDevice is.
flowchart TD
FRAME["1 · The frame"]
subgraph SUB["the substrate"]
WIN["2 · The window"]
B3D["3 · Blaze3D"]
end
subgraph PIPE["the pipeline"]
VIS["4 · Visibility and the frame graph"]
MESH["5 · Section meshing"]
MOD["6 · Models and atlases"]
ENT["7 · Entity rendering"]
BEN["8 · Block-entity rendering"]
SKY["9 · Lightmap, fog and sky"]
PART["10 · Particles"]
POST["11 · Post-processing"]
VIS --> MESH --> MOD --> ENT --> BEN --> SKY --> PART --> POST
end
FRAME --> SUB --> PIPE
The part as a substrate and a pipeline, numbered to the watch order, the frame first: an arrow is the next lecture, which is not the order a frame runs in.
The arrows in that figure are the order to watch in, which is not the order a frame runs in. Visibility hands section meshing the sections to compile, and a frame’s terrain is drawn from meshes an earlier frame made, so the fourth and fifth lectures feed each other. Inside a frame the order is different — the sky pass is declared before the main one, the lightmap is built before the world is drawn at all, and one of the five post chains runs as passes of the world’s own graph while the other four build one and throw it away.
Before you start
The client loop from Part X, and not optionally: it is the page that says when a frame happens and how many ticks ran before it, and this part begins exactly where that page’s frame zone opens. It is one of the two longest pages in Part X, so budget for it.
The resource system from Part II before models and atlases, which is a reload listener and leans on the barrier semantics taught there rather than restating them.
Environment attributes and timelines from Part IV before lightmap, fog and sky. Part IV owns that system; Part XI is one of its client-side consumers and does not re-teach it.
The client level, for what the thing being drawn is — a Level with its authority removed — and for the two
ways LevelExtractor is reached, pushed and pulled.
Watch in this order
- The frame — one method, two halves, and a wall between them. Fifteen profiler zones, six clocks disagreeing on purpose, and a failed surface acquisition that costs you the picture but not the work.
- The window — the substrate nothing else admits to
needing. A retry loop that settles the graphics backend before the window
exists, nineteen kinds of window event and the six the game is never told
about, and
NativeImage, the seam between a file and a texture. - Blaze3D — the game’s own graphics API, and the part’s vocabulary page. Four validating façades over two real backends, one of which is Vulkan and is the larger of the two.
- Visibility and the frame graph — what the frame decides to draw, and in what order. A reachability walk whose asymmetry — uncompiled sections stop it, empty ones do not — is why terrain reveals itself outward.
- Section meshing — where the triangles came from. A block is placed, a halo of positions goes dirty, the sections it touches are rebuilt from a snapshot — by default inline for your own block, on a worker for the server’s — and the new mesh swaps in all at once.
- Models and atlases — the reload pipeline behind block, item and particle quads. Twelve atlases stitched in parallel, one barrier, and a quad whose chunk layer is read out of its sprite’s pixels.
- Entity rendering — every entity in the world, in four stages, none of which is called render. The zombie is animated at least twice per frame.
- Block-entity rendering — the same four stages with three differences that show. A chest’s block model is empty, a block entity is never frustum-tested on its own, and the chest in your hand is drawn by a different renderer at a different partial tick.
- Lightmap, fog and sky — what colour all of it is. One question asked by four of the five renderers, most of which no longer know what time it is.
- Particles — the part’s policy page: three distance rules for a particle or a level event, enforced in three places and three readers of one setting who disagree about what its values mean, with a break puff that answers to almost none of them.
- Post-processing — the closer. Five JSON-declared shader chains, which is how the pause-menu blur and the creeper spectator shader turn out to be the same machine — and a resource pack can rewrite all five and add its own.
Four and five are a pair — one journey seen from its two ends — and so are seven and eight, the second of which is written as the differences from the first. One to three can be watched in order or in the order one, three, two; the window is the page a viewer is most likely to skip and least likely to regret.
Where the part stops
What draws over the world rather than in it — screens, the HUD, the render
tree they record into and the text inside them — is Part X, from the GUI
render tree onward; what the server chose
to tell this client is Part IX. The packages themselves cross the boundary in
both directions: the GUI’s render states and the debug renderers under client/renderer
count against Part XI while being Part X’s subjects, and client/resources/model
counts against Part X while being the subject of models and
atlases.
Everything else left out is the families — one class per mob model, one per renderer, one per particle, one file per GL or Vulkan call site — which is why 41% of the part’s lines are named on no page in the book, the second-highest figure in the book after Part V’s. Teaching each family’s shape and declining its instances leaves a great many lines untouched, and that is the trade, made on purpose.
Reference this part uses
Submit phases and feature renderers is
the catalogue behind entity rendering and
block-entity rendering: the fifteen phases a
submitted feature can land in, and the twelve renderers that write the
vertices. The threads for the two that matter
here — the Render thread most of the part runs on, and the background pool that
meshes sections — and diagram lanes for the
abbreviations the figures use. Naming drift
is worth having open throughout: the world-facing half of the
old LevelRenderer is now LevelExtractor, and many of the names a 1.21-era
reader reaches for moved with it. The
glossary for extract, render state, frame
graph, partial tick, atlas, special model renderer and built-in block
model.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
The frame
Verified against Minecraft 26.3 · Part XI · one frame: from acquiring a surface texture to handing it back, and the wall in the middle that the drawing half is not allowed to look past.
The number in the top-left says 143 fps, and each of those 143 is one call to
Minecraft.renderFrame — one method with two halves. The first walks the
live game and copies everything drawable into a GameRenderState; the second
draws that state, and reads the live game only where the wall below leaks. Before either half runs, the frame asks
the surface the window was made with for somewhere to put the picture, and the
surprising thing is what happens when that request fails. It does not skip
the frame. It skips the picture. The world still renders in full into
GameRenderer.mainRenderTarget, the GUI still goes on top, the framerate
limiter still parks; only the blit and the present are guarded on a surface
having been acquired, and both are quietly skipped. A minimized window is the same
story — a client drawing complete frames that nobody will ever see.
All of this is one thread — the same one that ticked the world a moment earlier. How many ticks ran before the frame, and what paced it afterwards, is the client loop; this page starts where that page’s frame zone opens.
The cast
| class | what it decides | thread |
|---|---|---|
Minecraft | the zones, the acquire guard, and whether this frame advances game time at all | Render thread |
GameRenderer | the frame’s owner of everything drawn — the snapshot target, the main render target, the camera, the lightmaps | Render thread |
GameRenderState | what the drawing half is allowed to read, and therefore where the wall is | written in extract, read in render |
Camera | where the eye is, how wide the view is, and how wide the cull frustum is — which is not the same number | Render thread, but ticked from GameRenderer.tick |
LevelExtractor | which of the live world becomes drawable state, at one partial tick per entity | Render thread |
LevelRenderer | the world half — its render handed a CameraRenderState, never a Camera | Render thread |
GuiRenderer | the GUI half, with its own StagedVertexBuffer | Render thread |
GpuSurface | whether there is anywhere to put the picture: acquire, blit, present | Render thread |
The zones a frame is made of
The profiler zones in order are the shortest honest description of what a
frame is, and the second column is the part a name does not tell you: who
pushes it. Eleven of the fifteen are Minecraft.renderFrame’s
own. render belongs to GameRenderer.render, which is why the F3 pie chart
puts the whole of the drawing half under one slice that Minecraft never
named.
| zone | pushed by | what happens in it |
|---|---|---|
| update window | Minecraft.renderFrame | the window’s one per-frame call, a surface reconfigure if one is due, then GpuSurface.acquireNextTexture |
| update | Minecraft.renderFrame | the real-time clock, the GPU timer query when something wants it, Minecraft.pauseIfInactive, the GUI, the client light engine on a ticking frame, then Minecraft.pick |
| camera | GameRenderer.update, inside update | Camera.update alone |
| extract | Minecraft.renderFrame | the wall: the window and the options, then on a frame with a world to draw the lightmap, the camera and the level, then the GUI, copied into GameRenderState |
| gpuAsync | Minecraft.renderFrame | RenderSystem.executePendingTasks drains signalled fences, and nothing else; it is closed before the drawing starts |
| render | GameRenderer.render | the resize, the clear, on a frame with a world to draw the lightmap, then the two zones below |
| world | GameRenderer.render, inside render | only on a frame with a level: matrices, fog, level, hand, screenEffects, then the outline composite and the post-effect chains |
| gui | GameRenderer.render, inside render | the GUI, drawn under the one-pixel lightmap |
| swapchainBlit | Minecraft.renderFrame | the blit from GameRenderer.mainRenderTarget into the surface texture |
| tracyCapture | Minecraft.renderFrame | only with Tracy frame capture on: the main target copied out for Tracy |
| submit | Minecraft.renderFrame | CommandEncoder.submit, surface or no surface |
| present | Minecraft.renderFrame | GpuSurface.present |
| endFrame | Minecraft.renderFrame | Tracy’s end of frame if it is capturing, the dynamic uniforms reset, LevelRenderer.endFrame |
| frameLimiter | Minecraft.renderFrame | the parking, spending the limit extract snapshotted |
| fpsUpdate | Minecraft.renderFrame | the counter behind the number in the top-left |
Drawn as calls rather than zones, the same frame shows who calls whom:
Minecraft.renderFrame hands GameRenderer its update, its extract and its
render, and only the two ends of the frame touch the surface’s texture.
sequenceDiagram
participant MC as Minecraft
participant GpuS as GpuSurface
participant GR as GameRenderer
participant LX as LevelExtractor
participant LR as LevelRenderer
participant GuiR as GuiRenderer
MC->>GpuS: GpuSurface.acquireNextTexture, if the surface is valid — a throw invalidates it
MC->>MC: update — clock, pause check, GUI, ClientLevel.update on a ticking frame
MC->>GR: GameRenderer.update — the camera zone, then the frame's post chains listed
MC->>MC: Minecraft.pick writes Minecraft.hitResult
MC->>GR: GameRenderer.extract — the window and the options
opt there is a world to draw
GR->>GR: the lightmap and the camera
GR->>LX: LevelExtractor.extract — the level
end
GR->>GR: the GUI
Note over MC,GuiR: the wall — nearly sealed at LevelRenderer.render, leaky one level up
MC->>MC: gpuAsync — the signalled fences drained
MC->>GR: GameRenderer.render — resize, clear
opt there is a world to draw
GR->>GR: the lightmap, then the world zone
GR->>LR: LevelRenderer.render with a CameraRenderState
GR->>GR: the hand and the screen effects
GR->>LR: LevelRenderer.blitEntityOutline
GR->>GR: the post chains
end
GR->>GuiR: GuiRenderer.render, then GuiRenderer.endFrame
opt GpuSurface.isAcquired
MC->>GpuS: GpuSurface.blitFromTexture from the main target
end
MC->>MC: submit — the frame's GPU work, surface or none
opt GpuSurface.isAcquired
MC->>GpuS: GpuSurface.present
end
Note over MC: the zones endFrame, frameLimiter, fpsUpdate
One frame as the calls Minecraft.renderFrame makes, with GameRenderer’s
work under the three it hands over. The surface’s texture is touched only at
the two ends, and only the blit and the present are guarded on having one.
Read it as acquire, snapshot, draw, present: everything but the blit and the present is paid for whether or not a surface was acquired.
Acquire, and the frame that carries on without one
The update window zone does three things, and the first belongs to
somebody else: Window.updateFullscreenIfChanged at the very top of it — the one per-frame
call of the window
— then a reconfigure of the surface if it needs one, then
GpuSurface.acquireNextTexture. Vsync is a
GpuSurface.PresentMode in that
configuration, which the OpenGL surface turns into a swap interval, so toggling
it in the options calls Minecraft.invalidateSurfaceConfiguration, and the
next frame’s update window reconfigures the surface.
When the acquisition throws, the surface is marked invalid, a reconfigure is
scheduled, and the frame goes on as if nothing had happened but for a line in
the log. Exactly two later statements re-test whether a surface is
held, and both are in Minecraft.renderFrame itself: the blit and the
present are each guarded on GpuSurface.isAcquired, with nothing on the
other branch.
The tolerance is the caller’s, not the surface’s — GpuSurface.blitFromTexture
and GpuSurface.present both throw, in the façade the game holds, if you
reach them without one. Everything
before those two guards — the extract, the world, the GUI — is paid in
full.
There is one guard on the whole method, and it is about re-entry rather than
failure: if the surface is already acquired when Minecraft.renderFrame is
called, the call is a silent no-op.
What a minimized client actually stops doing
On the OpenGL backend, a minimized window is the same story after one refused
acquire. Minecraft.renderFrame holds any surface reconfigure back until the
window is restored, and GlSurface.acquireNextTexture refuses an iconified
window outright, so the first minimized frame fails to acquire, the surface is
marked invalid, and no acquire is tried again until the window comes back. The
three statements that drop out are the acquire and the two guarded
ones — which is nothing next to what the frame still pays for. The saving
comes from somewhere else entirely.
FramerateLimitTracker.getThrottleReason tests iconification first, ahead
of idleness and the menu, and FramerateLimitTracker.getFramerateLimit turns
that answer into a limit of ten — so frameLimiter
parks the thread for most of every hundred milliseconds and the client draws
its unseen frames about ten times a second instead of at the player’s
setting. On top of that, losing focus for half a second, with no screen up and pause on lost focus
on, opens the pause menu through Minecraft.pauseIfInactive, and that menu’s vote stops a singleplayer world not opened to LAN, so every
frame after that draws a world that has stopped.
Update and extract: six clocks in one frame
The update zone advances the real-time clock, reads
Minecraft.timerQuery — a TimerQuery, the GPU-side stopwatch behind the F3
utilisation figure. It brackets almost the whole frame, from here through
the blit, runs only while the debug screen’s GPU line is shown or a metrics
recording is running, and is only started when the last one has been
collected, so not every frame is measured — runs
Minecraft.pauseIfInactive and updates the GUI. Then
GameRenderer.update calls Camera.update — that one call is the camera
zone — and Minecraft.renderFrame follows it with Minecraft.pick, a
private method of Minecraft and not the renderer’s, which writes
Minecraft.hitResult for the crosshair and the block outline to find later.
Behind all of this sits the flag the rest of the page keeps leaning on.
Minecraft.renderFrame is passed a boolean saying whether this frame
advances game time, and a frame passed false is a non-ticking frame: no
ticks ran before it, ClientLevel.update does not run the client’s own
light engine, and the whole world half of the drawing is skipped, leaving a
GUI-only picture — the half GUI and
screens owns. Four call sites always pass false:
the Minecraft constructor, once, as it finishes; the two loops that wait for
the integrated server to start and to stop; and
Minecraft.setScreenAndShow, which forces a single frame so that a screen
appears during blocking work. And the loop’s own call passes false for every
frame after the client has recovered from running out of memory. Every other
frame is a ticking one, and the term means only that.
Camera is split three ways across the client, and two of the three are here.
Camera.tick — driven from GameRenderer.tick, not from the frame — smooths
the eye height and the field-of-view modifier and advances the camera’s
EnvironmentAttributeProbe. Camera.update does the frame’s work:
Camera.alignWithEntity, Camera.calculateFov, Camera.prepareCullFrustum,
Camera.setupPerspective. Camera.extractRenderState then copies the result
across the wall.
Six partial ticks, and none of them is the one
Extract opens by snapshotting the framerate limit into
GameRenderState.framerateLimit and goes on to copy the window and the options,
and on a frame with a world to draw the lightmap, the camera and the level. It is here that the frame’s oddest
number appears: there is no such thing as the partial tick of a frame.
There are six, they disagree on purpose, and two of them are not partial
ticks at all.
| who is interpolated | which value | what it ignores |
|---|---|---|
| the world | DeltaTracker.getGameTimeDeltaPartialTick | nothing — frozen time is honoured |
| the camera, and the held item as it is posed | Camera.getCameraEntityPartialTicks | freezing, unless the camera entity itself is frozen, which a player never is |
| the lightmap | a literal one | everything — it is extracted fully advanced |
| screens and overlays | DeltaTracker.getGameTimeDeltaTicks | the fraction — this is the whole delta since the last frame, not a position inside a tick |
| the autosave indicator and the menus’ panorama | DeltaTracker.getRealtimeDeltaTicks | the game clock, and anything past seven ticks, where it clamps to a half |
| each entity, separately | its own frozen-honouring value, asked for by LevelExtractor | the world’s single answer |
The last row is the one you can see. Under /tick freeze a mob pins at the
end of its last tick while players go on interpolating — in the same frame,
from the same extract. The exception is worth knowing, because it is the one
a player rides: TickRateManager.isEntityFrozen excludes anything with a
player aboard, so the horse under you keeps moving smoothly while the horse
beside you is a statue.
The wall, and the one level at which it is real
LevelRenderer.render is handed state and reads no live game state: no
ClientLevel, and nothing from Minecraft but the level renderer itself,
which it reaches through Minecraft.getInstance. That is the wall, and one level down it holds but for a few reaches by reference: a moving block’s
render state carries the level’s light engine and is lit from it at prepare, a
sign’s carries the block entity’s own text (block-entity
rendering),
and a section compiled on the spot reads tints and light live through its
region (section meshing).
Beyond those it reaches back only into live renderer objects, for the main
target and the shader manager.
At the top it leaks, and the leak is sharper than the naming suggests.
GameRenderer.render takes whether there is a world to draw from the
snapshot, as GameRenderState.shouldRenderLevel, but inside the world half
GameRenderer.shouldRenderBlockOutline reads the
live camera entity during rendering, and in adventure or spectator mode
goes further: for a player who may not build it reads Minecraft.hitResult,
asks the game mode what it is and looks a BlockState up in the level, all
mid-draw. It is not the only reach: the boss bar’s world fog, the hand’s check
for spectator mode and the world-icon screenshot read live state inside
GameRenderer.render too. The interesting fact is not that a wall exists but that it is
drawn one level below where extract then render implies it is.
The resize the snapshot has already decided
A resize is handled inside the render half rather than before it:
GameRenderer.render opens by comparing GameRenderState.windowRenderState
against the main target’s size and resizing the renderer inline when they
differ. The snapshot is what the frame believes the window size to be, even
if the window has changed since.
What the drawing half holds, which is four buffers and a storage per drawer
The buffers are few. RenderBuffers holds RenderBuffers.fixedBufferPack
and the section-meshing scratch RenderBuffers.sectionBufferPool — capped by
processor count and again by a memory budget — which only section meshing
uses, and a single RenderBuffers.stagedVertexBuffer released by
RenderBuffers.endFrame, which the feature dispatcher writes every prepared
feature into, with the GUI keeping a second staged buffer of its own inside
GuiRenderer. Geometry submission goes through SubmitNodeCollector into a
SubmitNodeStorage, one per drawer
(entity rendering
owns what a submission is), drawn either inside the frame graph’s main pass in
visibility and the frame graph
or by FeatureRenderDispatcher.renderAllFeatures — which, despite the name,
is not how the level is drawn. It has four call sites: the held item, the
screen effects, the GUI’s item atlas and picture-in-picture. The last two are
why the GUI needs submit storage at all. The world’s submitted features are
prepared into the frame graph and drawn inside its main pass.
The hand and the screen effects, in a storage the level never sees
The first two of those four call sites share a storage of their own.
GameRenderer.handAndScreenSubmitNodeStorage collects both
GameRenderer.renderItemInHand — which hands the hand to FirstPersonHandsAndItemsRenderer, drawn under
its own projection after a depth clear so a held sword never intersects the
world — and, in the screenEffects zone that follows it,
ScreenEffectRenderer: the underwater overlay, the fire quad when you are
burning, the sprite of whatever block your head is inside, and the
item-activation flourish a totem plays. Each is submitted, prepared and
drawn inside its own zone, and neither ever reaches the level’s frame graph.
Blit, submit and present
The picture leaves the frame in three zones. swapchainBlit does the blit
from GameRenderer.mainRenderTarget to the acquired surface texture;
submit closes the frame’s GPU work with CommandEncoder.submit, surface or
no surface; and present hands the texture back with GpuSurface.present,
which on the OpenGL backend is a single call to SDL’s SDL_GL_SwapWindow.
With Tracy frame capture on, a tracyCapture zone between the blit and the
submit copies the main target out for Tracy.
The last three zones are bookkeeping. endFrame ends Tracy’s frame if Tracy
is capturing, resets the dynamic uniforms and calls LevelRenderer.endFrame.
frameLimiter spends the limit that extract snapshotted into GameRenderState.framerateLimit, parking only
below a threshold, so the top slider position parks only when
FramerateLimitTracker has capped or replaced the limit. What that
limit is — and the four cases in which FramerateLimitTracker has already
capped or replaced the player’s option — belongs to the client
loop;
this page only spends it. Then fpsUpdate, and the frame is over.
Questions players ask
Why does lowering a spyglass never reveal a hole in the world? Because
the cull frustum is deliberately wider than the camera.
Camera.createProjectionMatrixForCulling builds its matrix from the larger
of the current and the configured field of view; Camera.prepareCullFrustum
turns that into Camera.cullFrustum, and Camera.extractRenderState copies
it across the wall into CameraRenderState.cullFrustum. Sprinting and flying raise the live FOV
above the option, so for them the maximum does nothing. It bites when
something narrows the view — a spyglass, a drawn bow, the dying-camera
effect — where culling against the configured FOV keeps the geometry a
narrowed view would have thrown away.
Why is the HUD not shaded by the light the player is standing in?
Because for the whole of the gui zone GameRenderer.lightmap hands out a
one-pixel white texture instead of the real one. GameRenderer.useUiLightmap
is set on either side of exactly that zone and nowhere else; which two
textures it is switching between belongs to lightmap, fog and
sky.
Why does the world go strange when spectating a creeper? Because the post-effect chain is chosen by what you are spectating rather than by an option, at the end of the world block and before any GUI. Which chain, through which door, and what each does to the picture belong to post-processing.
Where does the main menu’s panorama come from? From six images loaded as
one cube-map texture and drawn as a turning cube, and the game can take the six itself on the same two halves,
though only with a debug flag set: Minecraft.grabPanoramixScreenshot runs GameRenderer.update,
GameRenderer.extract and GameRenderer.renderLevel six times at
4096×4096, with a fixed delta of one and the camera in panoramic mode —
which is what CameraRenderState.isPanoramicMode exists for. A second,
silent screenshot path inside the world half writes the world icon,
singleplayer only, and only once enough sections have been rendered.
For a 1.21-era reader. The frame is now extract then render:
GameRenderer.extractcopies the live game into aGameRenderState, andLevelRenderer.renderis handed aCameraRenderStaterather than aCamera. Names to stop hunting for: Minecraft.getMainRenderTarget (nowGameRenderer.mainRenderTarget), Camera.setup (nowCamera.updateplusCamera.extractRenderState), GameRenderer.getProjectionMatrix, Window.updateDisplay, LightTexture (nowLightmap), ItemInHandRenderer (nowFirstPersonHandsAndItemsRenderer) and MultiBufferSource.
Where to look
Minecraft.renderFrame — the frame is one method, and its profiler zones are
its table of contents. Then GameRenderer.extract and GameRenderer.render
for the wall between live objects and drawing, GameRenderer.tick for the
per-tick half nobody expects a renderer to have, Camera.update for how the
view is decided, and GpuSurface.present for where a frame ends. The GPU
abstraction underneath all of it has its own page, blaze3d.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
The window
Verified against Minecraft 26.3 · Part XI · before the first frame: the game finds out which graphics backend it has by which device survives, and only then asks the operating system for a window.
The process is a second old. There is no world, no renderer, no resource
pack and nothing to draw. RenderSystem.initBackendSystem has just brought
SDL up, NativeLibrariesBootstrap has probed for the GL and Vulkan loaders
and MonitorManager has enumerated the monitors and the video modes each of
them offers. Now Minecraft wants a window — and it cannot ask for one
without already having decided how the pixels will be drawn, because an
OpenGL window and a Vulkan window are asked for with different flags. So
the device comes first and the window second: each candidate backend in
turn loads its library and tries to build a whole device before the game has
any window at all — OpenGL’s device makes a hidden window of its own to hold
its context — and only the backend that survives is asked for the window,
once. A window that fails at that point is not handed to the next candidate;
it ends the game with a crash report.
That loop is the first half of the page. The second is what the window
turns into once it exists: this is Part XI’s platform layer, not
just its window — the nineteen kinds of event the window answers, the
three sizes every GUI element is placed against, and NativeImage, the CPU
image type every texture, screenshot and skin, and every glyph but the
unihex font’s, passes through on its way to or from a file. They share a package and a role rather
than a scenario, and the role is everything between the game and the
machine it is running on.
The frame is the lecture you watch first, and it opens on a
surface that has already been acquired. This page is what created it.
Input and keybinds opens on an event that
has already arrived, and blaze3d on a GpuDevice that already
exists. All three of them start here.
The cast
| class | what it decides | thread |
|---|---|---|
Minecraft | which backends to try, in which order, and when to give up | Render thread |
Window | the SDL window handle, the three sizes, and every fullscreen transition | Render thread |
GpuBackend | its library, its device, and the flags a window for it is made with | Render thread |
MonitorManager | which monitors the game knows about, kept current by SDL’s display events | Render thread |
Monitor | which VideoMode an exclusive fullscreen switch asks for | Render thread |
WindowEventHandler | what the window tells the game: size, cursor and fullscreen | Render thread |
FramerateLimitTracker | what an iconified, idle or menu-bound window is allowed to cost | Render thread |
NativeImage | the CPU-side pixels between a file and a texture | native memory, closed by its owner |
Two of those rows live outside com/mojang/blaze3d/platform and the other
six in it: Minecraft is the game’s own, and GpuBackend sits in
renderpearl/api with
the façades.
None of the package exists on the server — server-classes.txt has no entry
under com/mojang/blaze3d at all — and most of it runs on the Render thread,
which is one of the four:
images are decoded and mipmapped on reload workers (a downloaded skin on the
download pool) and written on the IO pool,
and the shutdown watchdog waits on a thread of its own.
Trying backends until one of them makes a device
The startup path is a retry loop, and it is drawn as a flowchart rather than a conversation because the shape is the fact: the loop encloses the device and leaves the window outside it. A backend that cannot load its library and a backend that cannot make a device land in the same handler, which unloads the library only in the second case, and both hand the next candidate a clean slate.
flowchart TD
START["RenderSystem.initBackendSystem starts SDL, MonitorManager lists the displays"]
subgraph LOOP["each candidate in turn"]
NEXT{"a candidate left?"}
LIB["GpuBackend.loadLibrary"]
Q1{"did it load?"}
DEV["GpuBackend.createDevice, with no window yet"]
Q2{"a device?"}
UNL["GpuBackend.unloadLibrary"]
end
BOX["MessageBox.error, and the game never starts"]
RS["RenderSystem.initRenderer with that device"]
WIN["the one Window, from GpuBackend.createWindow"]
Q3{"a window?"}
CRASH["a crash report, and no next candidate"]
DONE["Window.setIcon, then GpuDevice.createSurface on its handle"]
START --> NEXT
NEXT -- "yes" --> LIB --> Q1
Q1 -- "yes" --> DEV --> Q2
Q1 -- "no" --> NEXT
Q2 -- "no" --> UNL --> NEXT
NEXT -- "no" --> BOX
Q2 -- "yes" --> RS --> WIN --> Q3
Q3 -- "yes" --> DONE
Q3 -- "no" --> CRASH
The startup retry loop, the box being the loop: a library or a device that fails sends the next candidate in, and the loop is left by a device that survives or by running out of candidates. The window is made once, outside the loop, and a window that fails is a crash rather than a retry.
The list is never one candidate long. PreferredGraphicsApi.getBackendsToTry
returns an ordered pair: every setting has the other API behind it as a
fallback, the default is OpenGL-first, and GlBackend and VulkanBackend are
the two things the loop above is choosing between. A start that ended before it
finished loading, by a crash or otherwise, downgrades the preference one step on the next start — a
Vulkan preference becomes the default, and the default becomes OpenGL — so a
client that keeps crashing on boot comes down to the safest option it has, and
stays there until you set it again. That is why
asking for Vulkan does not always get it.
What the window is asked for is a DisplayData: a size, an optional
fullscreen size and a fullscreen flag, with DisplayData.withSize and
DisplayData.withFullscreen applying the saved size and fullscreen options,
or, after a start that never finished loading, forcing windowed, before the
window is made. What
comes back is a Window holding a Window.handle — and neither a
GpuDevice nor the backend that made it. The window uses the backend once,
to be created, and the device meets the window only afterwards, when
GpuDevice.createSurface is handed its handle.
SDL, STB and FreeType, reached through LWJGL, are most of what the package sits on, and it
calls almost nothing else in the game on its way down. The exceptions are
about passing something upward, an event or a failure: SDLEventHandler
hands the input up, and a failure goes to Minecraft, CrashReport, and the
one piece of the dedicated server the client borrows — ClientShutdownWatchdog
builds its report with ServerWatchdog.createWatchdogCrashReport, which is
the only net.minecraft.server.dedicated name anything here touches. Above the
package, Minecraft drives startup and the two per-frame calls
below, KeyboardHandler and MouseHandler take the input events and the
clipboard, VideoSettingsScreen drives the fullscreen and video-mode
controls, and Screenshot and TextureManager want NativeImage. What the
player’s saved choices reach is Options: an override width and height, the
fullscreen flag and video-mode string, exclusive fullscreen, the GUI scale,
and the graphics-API preference that ordered the loop above. The window’s
position is not among them — it is a field the move event keeps and
nobody saves.
How the loop knows why a device did not appear
Minecraft has to be able to say why every candidate failed, because it may
have to show the player. A failed attempt arrives as a
BackendCreationException, carrying a message and a
BackendCreationException.Reason, and where the failure is SDL’s — a library
that would not load, a context or a window that would not appear — the
backend reads SDL_GetError on the spot and writes it into the message. The
loop appends each attempt’s name and message to the text MessageBox.error
shows if nobody survives, and keeps one exception in
Minecraft.backendCreationException for the crash report and the telemetry,
where an OpenGL-missing failure never displaces an earlier one.
Errors are read where they happen: where the game checks an SDL call’s result
it mostly reads SDL_GetError there and then, or once in the method that made
the call, and what SDL
logs of its own accord goes to the game’s log through SdlDebug, which
RenderSystem.initBackendSystem installs before SDL starts.
Window.setErrorSection writes a label for the crash report — Startup from the
constructor, then Pre render, Render and Post render on every pass of
the loop — which is why a crash report’s Window category names the phase
the game was in.
Nineteen events, and the six the game is never told about
The window registers nothing with the operating system. At the top of every
pass of the client loop, RenderSystem.pollEvents runs
SDLEventHandler.pollEvents, which drains SDL’s queue: the keyboard, mouse
and drop events it deals with itself, and everything else goes to
Window.handleEvent, which answers nineteen kinds of event and ignores the
rest. WindowEventHandler — a four-method interface that Minecraft
implements — is nearly the whole of what a window says back to the game, and
the window only ever reaches for three of those four methods.
flowchart LR
SEH["SDLEventHandler.pollEvents, once per loop pass"]
IN["keys, text, mouse, dropped files"]
KMH["KeyboardHandler and MouseHandler"]
WHE["Window.handleEvent, nineteen kinds"]
TOLD["pixel size, display or mode, cursor, fullscreen — seven"]
WEH["WindowEventHandler, implemented by Minecraft"]
IC["minimised, maximised, restored — three"]
MC["Minecraft.invalidateSurfaceConfiguration"]
QT["quit, close, terminate — three"]
CB["Window.shouldClose, and the close callback"]
LOOK["moved, resized, focus in or out — four"]
W["a Window field, looked up later"]
DS["display added or removed — two"]
MM["the monitors MonitorManager keeps, looked up later"]
SEH --> IN --> KMH
SEH --> WHE
WHE --> TOLD --> WEH
WHE --> IC --> MC
IC --> W
WHE --> QT --> CB
WHE --> LOOK --> W
WHE --> DS --> MM
The poll’s one fork and the window’s five groups of event, with how many of the nineteen kinds each group holds. Input never reaches the window, and the moves, resizes, focus changes and displays arriving or leaving are the six the game is never told.
WindowEventHandler.framebufferSizeChanged,
WindowEventHandler.cursorEntered and
WindowEventHandler.fullscreenStateChanged are the three. A new pixel size, a
move to another display and a change of the display’s mode all end in the
first; the cursor crossing the window’s edge, in either direction, in the
second; and the operating system taking the window into or out of fullscreen
in the third, which writes the new state back into the fullscreen option. A
move, a resize, focus gained or lost and a display arriving or leaving end in
a field or in MonitorManager — Window.getX, Window.getY,
Window.getScreenWidth, Window.isFocused and MonitorManager.getMonitor
are what anyone asks instead, whenever they get round to it — so six of the
nineteen are things the game is never told, only things it can look up.
Minimising reaches the game outside the interface: Window.isIconified reads
a field like the others, but Window also calls
Minecraft.invalidateSurfaceConfiguration itself, on the way down and on the
way back. While the flag is set the frame will not reconfigure its surface,
and the surface was handed the same flag when GpuDevice.createSurface made
it, which the OpenGL surface uses to refuse an acquire; otherwise the frame
runs in full, and where the real saving comes from is told in the
frame.
The fourth method on the interface is the odd one.
WindowEventHandler.resizeGui is never called by Window at all: its callers
are Minecraft and Options, which is to say the game calling itself — at
startup, after every new framebuffer size, and when the GUI scale or a font
option changes. And WindowEventHandler.framebufferSizeChanged
is not only an event’s answer — Window.updateFullscreenIfChanged,
Window.changeFullscreenVideoMode and Window.setExclusiveFullscreen all
raise it directly, which is how F11 and a video-mode switch reach the renderer
by the same route a dragged window corner does.
Notice what is not among the nineteen: keys, characters, mouse buttons,
cursor motion, scrolling and dropped files. SDLEventHandler.pollEvents takes
all of those off the queue before the window sees any and hands them to
KeyboardHandler and MouseHandler — see input and
keybinds.
The one callback, which is not the constructor’s
The window does hold one callback, and Minecraft sets it after the
constructor has run: the window-close callback, the one that runs when you
click the X rather than quitting from the menu — the close request is one of
the nineteen events, and Window answers it by setting Window.shouldClose
and running the callback. It is registered late because what it does is not
the window’s business at all — it is one of the two places
ClientShutdownWatchdog is armed, and it is the one live through the teardown that follows a close from the
window, which the other arming, after Minecraft.exitWorldAndClose returns, never sees. That is one reason the
game sometimes leaves a crash report behind after you close it. The two armings
and what each may
do are
the client loop’s.
Three sizes, and every misplaced GUI element is a confusion between them
| the size | how it is asked for | what it is |
|---|---|---|
| framebuffer | Window.getWidth, Window.getHeight | the pixels the renderer targets |
| screen | Window.getScreenWidth, Window.getScreenHeight | the window as the operating system reports it, which under DPI scaling is not the framebuffer |
| GUI-scaled | Window.getGuiScaledWidth, Window.getGuiScaledHeight | the framebuffer divided by an integer scale |
The integer scale is the part with a policy in it, and what the option
asks for is a ceiling the game is allowed to miss in both directions.
Window.calculateScale counts upward from one for as long as the framebuffer
divided by the next scale would still be at least Window.BASE_WIDTH by
Window.BASE_HEIGHT — 320 by 240 — so on a small window it stops below what
you asked for. Then, if the font needs unicode and the answer came out odd,
it adds one, which is the one case where the result comes back above the
ceiling. Window.setGuiScale takes whatever that was and stores it, deriving
the two scaled sizes by dividing and rounding up. A high-DPI display is what
makes the first two rows diverge, and a GUI element that lands in the wrong
place is nearly always code that read one of the three and meant another.
What the window does per frame, which is almost nothing
Two calls, both inside Minecraft.renderFrame, both in the update window
profiler zone: Window.updateFullscreenIfChanged at the very top of it, and
immediately after it a reconfigure-and-acquire of the GpuSurface — which is
renderpearl’s object, not the window’s (see
Blaze3D), and is the whole of the
window’s involvement in getting a picture onto the screen. Everything else
the window does is answering an event.
Window.updateFullscreenIfChanged is where F11 lands.
Window.setFullscreen, which the fullscreen option calls, sets the request,
Window.setWindowed applies one at once, WindowEventHandler.fullscreenStateChanged
reports the outcome back to the option, and Window.changeFullscreenVideoMode
with Window.getPreferredFullscreenVideoMode and
Window.setPreferredFullscreenVideoMode negotiate what exclusive fullscreen
turns into. Dragging the window to the other monitor is the same machinery
approached from the other end: MonitorManager.findBestMonitor asks SDL
which display the window is on, and Monitor.getPreferredVideoMode looks for
the saved preference among the modes this monitor offers, taking the
monitor’s current mode when there is no exact match — the monitor never
approximates; SDL does, afterwards, when Window asks it for the closest mode
it can set, and an exclusive switch that finds none falls back to borderless.
A Monitor is a record — a name, an SDL display id, its list of
VideoModes, the current one and its bounds — and
Window.getActiveVideoMode, the mode the window is showing, is where the
debug screen’s refresh rate comes from.
The one policy on this page that runs continuously is
FramerateLimitTracker, and what it watches is the window’s iconification, and
its focus only in exclusive fullscreen, where an unfocused window is throttled
as if it were iconified — anywhere else, losing focus is a
different mechanism with a different effect. Beside the window it watches the
idle time since the last input, when the inactivity option asks it to, and
whether a menu is up with no world. What it does with that, and the
four limits it caps or substitutes, belongs to the client
loop;
the frame
is where the limit gets spent.
NativeImage, the seam between a file and a texture
NativeImage is a NativeImage.Format, a width, a height and a pointer into
native memory. It is where every image the game reads from or writes to a file briefly is,
the unihex font’s glyphs aside, and it is not only textures. NativeImage.read is an STB decode from a stream, a byte array
or an NIO buffer — that is the PNG path. NativeImage.copyFromFont receives a
rasterised FreeType glyph. NativeImage.copyRect and NativeImage.fillRect
are how one image is cut out of or patched into another — an Unstitcher
source slicing a sheet apart before it is ever stitched, or
SkinTextureDownloader folding a legacy 64×32 skin into the modern layout —
while NativeImage.resizeSubRectTo has exactly one caller in the game, the
world icon being scaled down. NativeImage.mappedCopy, NativeImage.getPixel
and NativeImage.setPixel are the rest of the vocabulary. A downloaded skin
sits in one while it is being validated, and a screenshot arrives in one read
back off the GPU on its way to NativeImage.writeToFile. What it is not is
where an atlas is built: an atlas is assembled on the GPU, sprite by sprite,
as models and
atlases
explains.
NativeImage.computeTransparency is what a stitched sprite’s contents are
scanned with, and it is the reason a quad’s chunk layer is read out of its
sprite’s
pixels
— a rendering decision made by looking at the pixels of a file.
Because the memory is native, ownership is explicit: NativeImage.close
frees it, and NativeImage.untrack only takes an image out of LWJGL’s leak
tracking, for the few special glyphs kept for the life of the process.
The corners the story does not pass through
Two of them are worth a sentence each because a player meets them. The cursor
stops changing shape when Window.setAllowCursorChanges is clear — it is
driven by a player option on the mouse-settings screen and nothing else, and
with it clear every request is answered with the default arrow; the other half
of that problem is the platform’s, since CursorType.createStandardCursor
takes a fallback for the shapes a given system does not provide. And
TextureUtil, the odd one out because nothing about it is a window, is why a
mipmapped texture’s edges do not bleed:
before MipmapGenerator builds a sprite’s mip chain it runs one of two
repairs over it, when the sprite is not an item’s and its mipmap strategy
asks for one:
TextureUtil.solidify floods the nearest opaque colour outward into every
fully transparent pixel, and TextureUtil.fillEmptyAreasWithDarkColor fills
them with a darkened copy of the image’s darkest colour instead. The first is
the one that stops the bleed, since a plain average of four texels down a mip
level would otherwise pull in whatever colour the transparent ones held.
The rest of the package is genuinely a list, and the class index is where a
list belongs: clipboard and IME text, the cursor shapes, the window icon set,
the macOS and memory-tracking helpers, Lighting’s buffer of diffuse-light
directions, and InputConstants, the key and
mouse-button vocabulary every KeyMapping is written in. Transparency,
which sits here too, is the answer NativeImage.computeTransparency gives,
not pipeline state — that belongs to
Blaze3D, in
renderpearl.
For a 1.21-era reader. GLFW is gone; SDL3 does its work, and GLX went with it. The window no longer presents anything: Window.updateDisplay and Window.updateVsync are gone, presentation is the
GpuSurfaceprotocol (blaze3d) and vsync is aGpuSurface.PresentMode. ScreenManager is nowMonitorManager, in the same package (naming drift). The constructor now takes theGpuBackendthat makes the window.
Where to look
Minecraft’s constructor for the candidate loop, what happens when it runs
out of candidates, and the one window made after it.
SDLEventHandler.pollEvents and Window.handleEvent for where each event
goes, then Window.updateFullscreenIfChanged for the one call the window
gets on every frame. MonitorManager.findBestMonitor and
Monitor.getPreferredVideoMode for the fullscreen negotiation.
NativeImage.read and NativeImage.computeTransparency for the image type
the rest of Part XI is built on.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Blaze3D
Verified against Minecraft 26.3 · Part XI · one draw call, from a declared pipeline to a triangle — and the two backends that can serve it.
Open Video Settings, change Graphics API, restart, and the game comes back
looking exactly as it did. Nothing in the net.minecraft packages noticed,
because nothing in them talks to a driver: they talk to GpuDevice,
CommandEncoder and RenderPass, and a backend under
com/mojang/renderpearl/backend/opengl or
com/mojang/renderpearl/backend/vulkan turns that into real calls. What made
the swap possible is that the state machine left the game. Blend mode, depth
test, cull and polygon mode are fields of a RenderPipeline, declared once
and applied by the backend (by OpenGL at the first draw that uses the pipeline,
by Vulkan when it is bound), and
RenderSystem — the class that used to be the state machine — contains no GL
call at all. It did not vanish, though. It moved behind the backend boundary,
where GlStateManager still shadows the enable-and-disable toggles and still
elides the redundant ones.
Nothing in this page chooses which backend that is. The choice is made in
Minecraft, before Blaze3D exists, and which candidates it tries and in what
order belongs to the
window —
which is also why the option is a preference rather than an instruction. This
page is the vocabulary of the boundary once one of them has won; the frame
that drives it is the frame.
The cast
| class | what it decides | thread |
|---|---|---|
RenderSystem | the static holder: the device, the Render thread, the frame’s shared uniforms and buffers | Render thread |
GpuBackend | which API is alive — its library, the device, creation errors, and then the window | Render thread |
GpuDevice | what exists, and what the hardware will allow | Render thread |
CommandEncoder | whether a pass may open, and with which attachments | Render thread |
RenderPass | that the pipeline matches the attachments before a draw is allowed | Render thread |
GpuSurface | how a finished frame reaches the screen, and what vsync means | Render thread |
RenderPipeline | how to rasterise: shaders, blend, depth, cull, topology — declared, never called | declaration only, compiled on workers, bound on the Render thread |
BufferBuilder | vertex data — on the Render thread, except the builders chunk meshing runs on workers | Render thread, and the meshing workers |
Four objects the game only touches through a façade
The four are interfaces in renderpearl/api, and behind each sits one
concrete, validating class in renderpearl/frontend over one thin per-backend
interface. The figure draws those three rungs for one of the four, and the
table after it names them for all four: the game holds the left column, never
the other two.
classDiagram
class RenderPass {
<<interface>>
what the game holds
setPipeline()
drawIndexed()
}
class FrontendRenderPass {
checks the API contract
RenderPassBackend backend
}
class RenderPassBackend {
<<interface>>
one implementation per API
setPipeline()
drawIndexed()
}
RenderPass <|.. FrontendRenderPass
FrontendRenderPass *-- RenderPassBackend
RenderPassBackend <|.. GlRenderPass
RenderPassBackend <|.. VulkanRenderPass
One of the four façades as the objects behind it: the game holds a
RenderPass, which is a FrontendRenderPass wrapping one backend’s
RenderPassBackend. The API’s contract is checked in FrontendRenderPass, and
only the bottom row, GlRenderPass or VulkanRenderPass, knows which API is
running.
| the game holds | which checks | the backend implements |
|---|---|---|
GpuDevice | FrontendGpuDevice | GpuDeviceBackend |
CommandEncoder | FrontendCommandEncoder | CommandEncoderBackend |
RenderPass | FrontendRenderPass | RenderPassBackend |
GpuSurface | FrontendGpuSurface | GpuSurfaceBackend |
GpuBackend is the entry point and the exception: no façade, because it is
what exists before a device does, and it makes the device and the window. Everything else the device creates, or its encoder
does, and the device answers for the hardware too — as one record graph, not a pile of getters.
GpuDevice.getDeviceInfo returns a DeviceInfo whose fields include DeviceInfo.name,
DeviceInfo.backendName, DeviceInfo.isZZeroToOne, a DeviceFeatures of
eight booleans, a HintsAndWorkarounds, a DeviceType and a DeviceLimits
whose DeviceLimits.maxTextureSize, and what its
DeviceLimits.maxMemoryAllocationSize can hold, cap the window size.
That record is sniffed as well as queried, which is why the game cares
which GPU you have when it could simply ask the driver. GlHeuristics reads
the renderer and vendor strings to guess the device type, flags GL-over-D3D12
— assumed on Windows-on-ARM whatever the string says — and flags AMD for
anisotropy problems: the first changes how the backend maps buffers and stages
chunk uploads, the second which filtering a graphics preset picks.
The backend probes the reported maximum texture size rather than trusting it
too, halving a proxy allocation until the driver accepts one.
Who checks what
The façade owns the API-contract checks and the backend owns the state of
its own resources. FrontendCommandEncoder.createRenderPass validates the
attachment count against DeviceLimits.maxColorAttachments, each attachment’s
GpuTexture.USAGE_RENDER_ATTACHMENT bit, that the attachments are all one size,
that the render area fits, and that no pass is already open.
FrontendRenderPass.setPipeline checks the pipeline’s ColorTargetState list
against the pass’s attachments in both count and GpuFormat. The
GpuBufferSlice overload of FrontendRenderPass.setUniform checks the offset
against DeviceLimits.minUniformOffsetAlignment — the plain GpuBuffer
overload feeds it the whole buffer at offset zero, which cannot fail.
Underneath, the backend throws on its own account: GlBuffer for a buffer
mapped without persistent-mapping support, unreadably, unwritably or over two
gigabytes, and GlDevice GpuOutOfMemoryException on a failed allocation.
The development-only checks are the façade’s too, behind one flag: the only
validation in this whole tree gated on running from an IDE is
FrontendRenderPass’s draw-time check, behind
FrontendGpuDevice.STRICT_VALIDATION. Everything else the façade asserts, it
asserts in a shipped game, and the gated half is the one that bites: a draw
that breaks one of its rules — a missing uniform, an index or uniform buffer
used against its usage bits, a pipeline that wants a depth texture the pass
lacks — goes to the backend unchecked in a shipped game.
The thread assertions are not where a reader expects them either.
RenderSystem.assertOnRenderThread is called from nine classes, eight of
those sites inside RenderSystem itself: it guards RenderSystem’s own
mutable statics, the GL- and SDL-facing classes, the render targets and one
texture helper, while
FrontendGpuDevice, FrontendCommandEncoder and FrontendRenderPass assert
nothing at all. And GpuDevice.createCommandEncoder does not create an
encoder — FrontendGpuDevice hands back the one FrontendCommandEncoder it
built over the backend’s one encoder, so the game can call it fresh at every
use site and the is a pass open guard still sees every pass.
How tight the boundary is
OpenGL is imported by exactly fifteen files — fourteen in
com/mojang/renderpearl/backend/opengl, the fifteenth the native-library
bootstrap — and nothing else in the game references LWJGL’s OpenGL bindings.
Vulkan gets nearly the same treatment: outside
com/mojang/renderpearl/backend/vulkan, exactly two files import its bindings
— the same native-library bootstrap, and FrontendRenderPass, which borrows
two Vulkan indirect-command structs for their size when it validates an
indirect buffer, a small leak OpenGL has no counterpart to. The boundary is thinner still than that suggests in one
direction and thicker in the other: BackendCreationException, in
renderpearl/api where the game can see it, names eleven ways a backend can
fail to be created and eight of the eleven are Vulkan’s, so the neutral
façade layer knows rather a lot about one of the two APIs it is meant not to.
Graphics is not all of it either, and neither is the Render thread:
com/mojang/blaze3d/audio is the OpenAL wrapper, whose per-source calls run on the sound engine’s own thread — see the sound
engine.
Vulkan is not a stub
7,387 lines against 5,815 — the Vulkan backend against the OpenGL one, thirty-three files against twenty-nine.
It is the larger of the two trees: a real swapchain, pipelines built straight
from the SPIR-V the shared compiler produces, five required device
extensions, nine required features, and vendor-specific GPU crash breadcrumbs in
vulkan/checkpoints that OpenGL has no answer to.
VulkanBackend.checkBackendAvailable says why it is unavailable, though only
the default preference consults it.
The backends differ in all eight DeviceFeatures flags, and only one
difference is symmetric: Vulkan hardcodes five flags true that OpenGL derives
from extensions, OpenGL hardcodes DeviceFeatures.wireframeFillMode true where
Vulkan asks the device for fillModeNonSolid, and the mirrored pair is the two
direct multi-draw flavours, where OpenGL has the separate one and never the
interleaved one and Vulkan the interleaved one only if the driver offers
VK_EXT_multi_draw — so a Vulkan device can support neither. Not every draw
consults those flags: the six multi-draw and indirect entry points gate
unconditionally, RenderPass.draw and RenderPass.drawIndexed only when a
non-zero first instance is asked for, and RenderPass.drawMultipleIndexed —
the batched chunk path wherever terrain is not drawn indirect — not at all.
A pipeline is a record, not a sequence of calls
RenderPipeline is declarative and effectively immutable: a
RenderPipeline.getLocation name, two shader Identifiers, a
ShaderDefines, a list of BindGroupLayout, up to eight ColorTargetState,
an optional DepthStencilState, vertex bindings, a PolygonMode, a cull flag
and a PrimitiveTopology. RenderPipeline.Builder assembles one, and
composition is the static RenderPipeline.builder taking
RenderPipeline.Snippets, which RenderPipeline.Builder.buildSnippet
produces rather than consumes.
Blending is a BlendFunction record, most often a named one
(BlendFunction.TRANSLUCENT, BlendFunction.ADDITIVE…), rather than factors
set on the device, and RenderPipeline.Builder.build refuses a pipeline whose
blended colour targets do not all share one blend function, or that binds more than sixteen vertex
attributes. Depth is reversed-Z throughout: DepthStencilState.DEFAULT
compares greater-or-equal and RenderSystem.DEFAULT_DEPTH_CLEAR_VALUE is zero.
The catalogue lives on the game side: RenderPipelines registers the static
pipelines — RenderPipelines.GUI, RenderPipelines.LIGHTMAP,
RenderPipelines.SKY and a hundred and ninety-two more, ninety-three of them
the three stages of thirty-one OitPipelineSets — from a shallow tree of
snippets and the shared uniform-name sets in BindGroupLayouts, and
ShaderManager compiles what RenderPipelines.requiredPipelines and
RenderPipelines.optionalPipelines list.
What a pipeline does not say
A RenderPipeline says how to rasterise. It does not say which textures to
bind or which target to draw into. The textures are client/renderer/rendertype’s:
RenderType wraps a RenderPipeline with its texture bindings, a
TextureTransform, a LayeringTransform, an outline variant and the batching
predicates RenderType.canConsolidateConsecutiveGeometry and
RenderType.sortOnUpload. RenderTypes is the static catalogue, RenderSetup
builds the entries, and RenderType.prepare resolves one into a
PreparedRenderType — pipeline, texture bindings, uniform slice — at draw time,
and that draws into whatever RenderPass its caller has opened.
Buffers, uniforms, and the ring that resets every frame
The resource vocabulary is small: GpuBuffer with usage bits
(GpuBuffer.USAGE_VERTEX, GpuBuffer.USAGE_UNIFORM, GpuBuffer.USAGE_MAP_WRITE…)
and GpuBufferSlice over it, GpuTexture with its own and GpuTextureView
over it,
GpuSampler, GpuFence, GpuFormat, IndexType, PrimitiveTopology. Two of
those are where old habits break. Sampler state left the texture — GpuTexture
has no filter or wrap setters, filtering is an immutable GpuSampler bound per
draw, and SamplerCache eagerly creates all thirty-two combinations at startup
and throws if either enum ever gains a constant. And there are three shared
index buffers, not one: RenderSystem.getSequentialBuffer switches between a
quad buffer, a line buffer with different winding, and a one-to-one buffer.
The ring, and the blocks a shader gets for free
Per-draw uniform data does not come from per-draw uniform calls; it is carved
out of ring buffers, and the ring is what makes it safe: a slice handed out
this frame is still being read while the next frame is being built, so the
buffer is rotated rather than overwritten. DynamicGpuData and
DynamicGpuDataStorageMapped hand out GpuBufferSlices of a
MappableRingBuffer, and DynamicGpuDataStorageMapped.endFrame rotates it,
resets the write cursor and closes any buffer a growth left behind.
FogRenderer.regularBuffer is a
second ring, rotated the same way every frame, between the world and the GUI, while
GlobalSettingsUniform and ProjectionMatrixBuffer hold one buffer apiece
and rewrite it in place, which is all a frame-wide value needs. The first of
those is the Globals block, one of the four RenderSystem.bindDefaultUniforms
puts in front of every draw but the two depth-integrating passes — beside the
projection, the fog and the lighting — and its seven members are fixed in Java: the
camera position as an integer block position and the fraction left over,
the screen size, the glint strength, the time of day as a fraction of
twenty-four thousand ticks, the menu blur radius, and a flag for the
supersampled texture-filtering mode. Those four, and a post pass’s own sizes, are the whole of what a shader may
know without being told, and a post pipeline declares only the first of the
four, which is why a post chain that
wants something to vary per
frame
has to find it in Globals or in its own pass’s sizes. Per-frame
scratch comes from TransientMemory — one interface over the shared
TransientBlockAllocator, with GlTransientMemory and
VulkanTransientMemory behind it, each among the four longest classes of its
backend tree.
Blocks are packed by hand with Std140Builder, sized by Std140SizeCalculator.
Vertices, and the builders that are not on the Render thread
Vertex data is described by VertexFormat and VertexFormatElement (a plain
record of name, offset and GpuFormat) with the standard layouts in
DefaultVertexFormat, and built with ByteBufferBuilder and BufferBuilder
into a MeshData. Most BufferBuilders are on the Render thread like
everything else here; the exception is the use that matters most for
throughput, because chunk meshing builds its sections with BufferBuilders on
worker threads, a synchronous compile’s aside, and stages the result through UberGpuBuffer into a StagingBuffer, which is why
SectionRenderDispatcher has a spin-wait guarded by
RenderSystem.isOnRenderThread.
Targets, and the pass a draw is handed
Render targets are RenderTarget,
TextureTarget and MainTarget, the transient ones allocated through
GraphicsResourceAllocator — CrossFrameResourcePool implements it — and
declared in the FrameGraphBuilder of visibility and the frame
graph.
Which target a draw lands on is never in doubt: it is the one its
RenderPass was opened on, and PreparedRenderType.drawFromBuffer draws into
the pass its caller hands it. The GUI’s item atlas and its picture-in-picture
renderers open that pass on a texture of their own and hand it to
FeatureRenderDispatcher.renderAllFeatures, which is how an entity that turns to follow the mouse in an inventory screen is drawn by the
world’s machinery into a texture instead of onto the screen.
Shaders, and the reflection that checks them
ShaderManager loads shader sources and hands them to the device through
ShaderSource. Preprocessing happens once, in one place, and knowing where is
the whole of it: ShaderManager only reads every source and every
shaders/include file, on a worker, during a reload’s prepare; the
#include directives and the ShaderDefines are both resolved later, by
shaderc, when GlslCompiler.compileToSpv compiles a stage to SPIR-V — the
defines as macros, the includes through a callback that answers from
ShaderSource.getInclude. Both backends start from that SPIR-V, and the
checking happens before either sees it: PipelineBuilder reflects each
module with spirv-cross through SpvModule.reflect — which is what lets a
declared BindGroupLayout be checked against what the shader declares, and a
vertex format against the vertex shader’s inputs. Vulkan then takes the SPIR-V
as it is, and OpenGL’s GlPipelineRecompiler translates it back into GLSL for
the driver. It is all data on disk, alongside the chains in
post-processing.
One draw
Every drawing class comes through this one shape — LevelRenderer,
GuiRenderer, Lightmap and TextureAtlas among them — or joins it halfway, handed a pass
someone else opened, as FeatureRenderDispatcher is.
sequenceDiagram
participant Game as the game's own code
participant GD as GpuDevice
participant CE as CommandEncoder
participant RP as RenderPass
participant GlRP as GlRenderPass
participant GlCE as GlCommandEncoder
Game->>GD: createCommandEncoder — the same façade every time
Game->>CE: createRenderPass with a RenderPassDescriptor
CE->>CE: validate attachments, sizes, usage bits, render area, no pass open
CE->>GlCE: createRenderPass — a cached FBO bound, scissor, clear, viewport
CE-->>Game: the RenderPass, wrapping a GlRenderPass, to be closed
Game->>RP: setPipeline, from RenderSystem.getCompiledPipeline — formats must match
Game->>RP: RenderSystem.bindDefaultUniforms — Projection, Fog, Globals, Lighting
Game->>RP: setVertexBuffer, setIndexBuffer, setUniform per texture
Game->>RP: drawIndexed
RP->>GlRP: drawIndexed
GlRP->>GlCE: executeDraw — the VAO, the pipeline, the dirty uniforms
GlCE->>GlCE: the GL draw call itself
Game->>RP: close — debug groups must balance
RP->>CE: FrontendCommandEncoder.<br/>submitRenderPass
CE->>GlCE: submitRenderPass
One draw on the OpenGL backend: the game talks only to the three façade lanes
beside it, which check what can break the API’s contract and pass the work to
the two on the right. A single RenderPass.drawIndexed becomes a vertex array,
a pipeline, the dirty uniforms and the driver’s draw.
What the game and the three façades do is declaration and the contract’s
checks. In the two lanes on the right, one call is not one call: pass setup
alone binds a framebuffer, sets the scissor, clears and sets the viewport, and
the first RenderPass.drawIndexed of a pass binds a vertex array and an index
buffer, binds a program and applies its depth, cull, colour mask, blend and
polygon mode, walks the uniform and sampler bindings and finally draws. The
point is not that a draw is cheap. It is that the game never sees any of it.
A pipeline reaches RenderPass.setPipeline already compiled: the game asks
RenderSystem.getCompiledPipeline for it, and the current PipelineCache
answers by identity, compiling on a miss while the Render thread waits. The
reload is what keeps that off the frame: in the prepare half of every
resource reload ShaderManager compiles the static catalogue through
GpuDevice.compilePipeline — the SPIR-V and its reflection on workers, each
pipeline finished on the Render thread — and in the apply half it fills a
fresh PipelineCache and swaps it in with RenderSystem.setCurrentPipelineCache
only if every required pipeline compiled, so a pack that breaks one leaves the
old cache standing rather than a half-built one. The lazy path is left for
pipelines the reload did not compile, which in a stock game means the post
chains and
three item pipelines, RenderPipelines.ITEM_CUTOUT among them, that the
location-keyed required list drops because another pipeline reuses their
location. Run the trace on Vulkan and the game code is unchanged — dynamic
rendering replaces the framebuffer bind, and push descriptors the uniform
binding.
How a frame reaches the screen
Presentation is a four-step protocol, not a swap: GpuSurface.configure, then
GpuSurface.acquireNextTexture, then GpuSurface.blitFromTexture, then
GpuSurface.present. Vsync is not a toggle in that sequence but a
GpuSurface.PresentMode in the configuration: OpenGL offers a fixed pair of
modes, Vulkan whatever the driver enumerates for the surface
VulkanGpuSurface owns beside its swapchain, mailbox and relaxed FIFO included.
What stops the CPU running a hundred frames ahead of the GPU
Not the present. A two-deep submit fence is the pacing:
GlCommandEncoder rotates its transient memory and a small fence ring every
time it submits, and a submit that would outrun the ring waits. Results that
must come back from the GPU use GpuFence instead — a callback registered
with RenderSystem.queueFencedTask, run by RenderSystem.executePendingTasks
once a frame in the gpuAsync
zone, stopping at the first fence
that has not signalled. Its one registration site in the game is
GlCommandEncoder’s texture readback, and Vulkan routes the same callbacks
through its own destruction queue.
For a 1.21-era reader. Tesselator is gone: vertices go through a
BufferBuilderinto aMeshData, then aGpuBuffer. VertexFormat.Mode, VertexFormat.IndexType and TextureFormat are nowPrimitiveTopology, a top-levelIndexTypeandGpuFormat; GpuDevice.getRenderer and its siblings are theDeviceInforecord; and Window.updateDisplay isGpuSurface.present, with vsync aGpuSurface.PresentMode.PoseStackdid not move.
Where to look
RenderSystem for what the game holds, then GpuDevice, CommandEncoder and
RenderPass for the façade, and FrontendCommandEncoder and
FrontendRenderPass for its checks. RenderPipeline.Builder and
RenderPipelines for how a draw is declared, RenderTypes for how one is
dressed. GlCommandEncoder for an OpenGL draw, VulkanCommandEncoder for the
other answer, GpuSurface for where a frame ends.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Visibility and the frame graph
Verified against Minecraft 26.3 · Part XI · you fly forward in creative and the world opens out in front of you, one section at a time.
You fly forward in creative and the world arrives. It does not resolve as a wall of fog lifting all at once — it grows outward from where you already are, section by section, at exactly the rate the mesher can keep up with. That order is not the download order and it is not the mesher’s queue order. It falls out of an asymmetry inside the walk that decides what is visible at all: a section that has not been meshed yet is opaque to the walk and stops it dead, a section known to be empty is transparent and lets it straight through, and a section that meshed to nothing is transparent too unless it holds a block entity, which keeps it a real compiled mesh that happens to have no draws in it. Terrain reveals itself outward because the walk can only reach as far as the meshes that already exist.
The frame ends
where this page begins, and the page runs in the order the sections below do.
The first stage is the odd one, because it is on the far side of the wall:
LevelExtractor.applyFrustum runs near the top of extract, so the two things the extractor later takes from that list — the dirty sections and the block entities those sections hold — are already reading what this stage decided. Entities are not among them: they are tested one by one.
Everything after it is LevelRenderer.render, which gathers what was
submitted, declares the passes of a frame, draws the terrain and schedules
translucency work for a later frame — then re-runs the walk on its way out,
for the frame after this one.
The cast
| class | what it decides | thread |
|---|---|---|
LevelRenderer | which passes the frame declares, and how terrain is bucketed and finally drawn — it holds the visible list the extractor fills | Render thread |
SectionOcclusionGraph | which sections the walk can reach from the camera at all | full walk on Util.backgroundExecutor, partial walk here |
SectionOcclusionGraph.GraphState | the published result of a walk — swapped whole on a rebuild, extended in place by a partial walk | rebuilt by a worker, extended and read here |
LevelExtractor | whether the frustum is re-applied this frame, and so whether the visible list is rebuilt or reused | Render thread |
Frustum | which of the reached sections survive into LevelRenderer.visibleSections | Render thread |
FrameGraphBuilder | which passes exist, what each reads and writes, and what order they execute in | Render thread |
LevelTargetBundle | the named render targets the passes hand between them | Render thread |
ChunkSectionsToRender | how the bucketed terrain is issued: one indirect draw per bucket, split at the device’s limit, or one multi-draw per layer | Render thread |
Only one of those reads the world, and it is the one on the extract side:
LevelExtractor holds the ClientLevel and is the class that carries it
across the wall.
What the renderer still owns, on this side of it, is geometry, targets and
order.
The order a frame does this in, and why the figure loops
flowchart TD
subgraph EXT["extract, before the wall"]
S1["1 · visible: LevelExtractor.applyFrustum trims the graph into LevelRenderer.visibleSections"]
end
subgraph REN["LevelRenderer.render"]
S2["2 · submitted: LevelRenderer.submitFeatures, then FeatureRenderDispatcher.prepareFrame"]
S3["3 · the first passes declared: clear, and the sky if drawn"]
S4["4 · the visible sections bucketed into ChunkSectionsToRender"]
S3B["3 · the main pass and any outline chain declared, then FrameGraphBuilder.execute"]
S5["5 · translucent re-sorts, scheduled for a worker at the end of LevelRenderer.compileSections"]
S6["the walk that feeds stage one: SectionOcclusionGraph.update, partial"]
end
BG["a full walk, on Util.backgroundExecutor"]
S1 --> S2 --> S3 --> S4 --> S3B --> S5 --> S6
S6 -. "when one is due" .-> BG
S6 -. "raises the flag" .-> S1
BG -. "publishes, raises the flag" .-> S1
One frame’s five stages in the order they run, the first on the far side of
the wall and the other four inside LevelRenderer.render, which ends by
re-running the walk. The dotted arrows are a full rebuild handed to a worker
and the flag either walk can raise: stage one re-applies the frustum only when
that flag is up or the camera has turned.
Stage one has already happened when LevelRenderer.render is entered, and
the box at the end of LevelRenderer.render is the walk that feeds it being
re-run for the frame after this one, which is why the figure loops rather than
ending. Stages three and four
interleave: the terrain is bucketed after the first passes are declared and
before the main pass is, and drawn when FrameGraphBuilder.execute runs that
pass. The two bookkeeping stages
are the first and the fifth, and they are incomplete in two different ways:
the first is a cache, thrown away and rebuilt only when something
invalidates it, and the fifth is a budget, doing a fixed slice of the work
each frame and leaving the rest stale. Both are why a frame’s terrain cost
does not track what is on the screen.
The walk that decides what exists, and the frustum that only trims it
Visibility here is reachability, not a frustum test. SectionOcclusionGraph
starts at the camera’s own section, or with the camera above or below the world
the nearest layer of sections, and walks outward one neighbour at a time,
and — with smart cull on, which is the default — it may step from a section
into a neighbour only if the two faces involved can see each other through
that section’s geometry. That per-section answer is
a VisibilitySet, computed by VisGraph when the section was meshed — so the
question can you see through this section is decided once at compile time
and then read for free by every walk that passes through it. A wall of stone does not
hide the world behind it because a frustum test rejected it. It hides it
because the walk cannot get past.
The reached sections live in an Octree inside
SectionOcclusionGraph.GraphState, alongside the queue of sections whose
neighbours still need visiting. A rebuilt state is never edited into the
old one: the whole of it is published through an AtomicReference, because
SectionOcclusionGraph.scheduleFullUpdate runs the complete rebuild on
Util.backgroundExecutor and the Render thread has to keep reading the old
graph until the new one is ready. The rest of the walk happens on the Render thread, including SectionOcclusionGraph.runPartialUpdate, which does edit
the published state in place — it walks outward again from the sections that
SectionOcclusionGraph.schedulePropagationFrom flagged (a call a worker also makes when it compiles a section to
nothing), typically because a new mesh landed for them and their neighbours are worth trying again — which
is a real walk, not a drain, and it is where the outward reveal actually
advances.
SectionOcclusionGraph.update, at the very end of LevelRenderer.render, is
what runs both.
The frustum arrives after all of this, and only trims.
SectionOcclusionGraph.addSectionsInFrustum visits the octree, keeps what a
Frustum admits, and fills LevelRenderer.visibleSections plus the small
short-radius subset LevelRenderer.nearbyVisibleSections. Looking a section
up by position goes through LevelRenderer.viewArea.
Beyond sixty blocks the walk gets harder — and sixty blocks and three
sections are one number written twice.
SectionOcclusionGraph.MINIMUM_ADVANCED_CULLING_DISTANCE is sixty, and
SectionOcclusionGraph.MINIMUM_ADVANCED_CULLING_SECTION_DISTANCE is that
same distance converted to section coordinates, which comes out at three; the
test that uses it compares section coordinates on each axis separately. Out
past it, smart cull adds a ray march back toward the camera from the section being stepped out of, and rejects the neighbour if any section along
the line has not itself been reached by this walk. Inside it, nothing marches
— nearby geometry is cheap enough not to argue about.
Which is why the reveal is outward
The three states a section can be in are the whole trick. An uncompiled
section is opaque: the walk stops there and everything behind it stays
unreached. An empty section is transparent: the walk passes through
without needing a mesh at all. A section that compiled to nothing is
transparent too, unless it holds a block entity: then it keeps a real compiled
mesh with no draws in it and answers from its own VisibilitySet like any
other section. So as meshes land, the
frontier of the walk moves outward one shell at a time, and each newly meshed
section re-arms its neighbours through
SectionOcclusionGraph.schedulePropagationFrom.
Streaming is a separate handshake laid over the same walk. A section whose
chunk has not arrived yet is treated as neither opaque nor transparent — it is
parked, filed against the chunk it is waiting for, and resumed when
ClientChunkCache reports that chunk loaded. The walk then continues from
where it stopped rather than starting over.
The gate this stage controls is not only what gets drawn. Only visible sections are re-meshed, so a block that changes behind you, farther back than the few blocks the frustum is pulled back to cover, costs nothing until the frustum step lets its section back into the list, usually when you turn; how a section becomes triangles once it has been chosen — the dirty flags, the snapshot a worker reads, the compiler, the three chunk layers and the buffer arenas they upload into — is section meshing.
The visible list is a cache, not a per-frame computation
LevelExtractor.applyFrustum does not run every frame, and two different
clocks decide when it does. They are easy to run together and they are not
the same thing.
The walk is thrown away and redone when the camera crosses an eight-block
cell on any axis, when the field of view changes, or when the smart-cull
toggle changes. That is SectionOcclusionGraph.invalidateIfNeeded, and what it asks for is the full off-thread rebuild, which SectionOcclusionGraph.update then schedules.
The frustum step asks a different question — the walk’s result may still
be good while the set of it you can see is not — and it re-runs when either
of two things happens: the graph raises its own frustum-update flag, which a
completed full walk always does and a partial walk does whenever it added a
section inside the offset frustum; or the camera’s pitch or yaw crosses a
two-degree step. Turning your head therefore re-applies the frustum without
disturbing the walk at all, and between these events
LevelRenderer.visibleSections is simply the list from last time. Stand
still and stare, and the frame’s terrain cost does not move.
Everything is submitted before there is anywhere to put it
Entities and block entities do not draw themselves inside a pass. They are
gathered first, before a single pass has been declared.
LevelRenderer.submitFeatures runs LevelRenderer.submitEntities and
LevelRenderer.submitBlockEntities into LevelRenderer.submitNodeStorage,
and FeatureRenderDispatcher.prepareFrame groups everything submitted into a
FeatureRenderDispatcher.PreparedFrame. The passes declared in the next stage
capture that prepared frame and call into it; they never walk an entity list
themselves. What a submission contains, and how an entity produces one, is
entity rendering.
The ordering matters for a reason that only shows up in the next stage: the frame graph needs to know, while it is being declared, whether anything in this frame wants an outline. It can know that because the submission has already happened.
Declaring the passes, and why none of them is ever culled
LevelRenderer.render builds a graph and then executes it. Building it means
declaring resources and passes. FrameGraphBuilder.importExternal brings in
the targets that already exist outside the frame — the main render target and
the entity-outline target — and LevelRenderer declares, through FrameGraphBuilder.createInternal, up to
eight that exist only for the duration of this frame: seven for improved
transparency, when that option is on, and one depth target when an always-on-top gizmo is drawn while a post effect runs; the outline chain, in a frame that has one, declares its own swap target the same way. For the duration of this
frame is not the same as allocated this frame: every internal target in
the part comes out of one CrossFrameResourcePool, which keeps a released
target for three frames in case something asks again for that size and
format, so a target that is conceptually thrown away at the end of a frame is
usually the same GPU memory next frame. Each pass comes from
FrameGraphBuilder.addPass, states its dependencies with FramePass.reads
and FramePass.readsAndWrites, and states its body with FramePass.executes.
LevelTargetBundle is where the handles live under names —
LevelTargetBundle.main, LevelTargetBundle.entityOutline,
LevelTargetBundle.alwaysOnTopDepth, and the improved-transparency targets
from LevelTargetBundle.depthBounds to
LevelTargetBundle.oitTerrainWithWaterPatchDepth — and LevelRenderer.targets
is the bundle the frame threads through every declaration.
flowchart TD
CLEAR["clear — wipes the main target's colour and depth"]
SKY["sky — LevelRenderer.addSkyPass, if drawn"]
subgraph MAIN ["the main pass"]
direction TB
T1["opaque terrain — the OPAQUE draw group"]
T2["FeatureRenderDispatcher.PreparedFrame.<br/>executeSolid"]
D{"improved transparency?"}
T3["LevelRenderer.<br/>executeClassicTransparency"]
T4["LevelRenderer.executeOit, once per OitStage"]
T5["LevelRenderer.executeOutline, only with outlines: into the outline target"]
T6["LevelRenderer.executeSeeThrough, only if anything sees through"]
T7["LevelRenderer.executeAlwaysOnTop, only with always-on-top gizmos: depth cleared"]
T1 --> T2 --> D
D -->|"no"| T3
D -->|"yes"| T4
T3 --> T5
T4 --> T5
T5 --> T6 --> T7
end
OUT["entity outline post chain — only with outlines"]
CLEAR --> SKY
SKY --> MAIN
MAIN --> OUT
The passes LevelRenderer.render declares, with the one LevelRenderer.addMainPass declares opened up. The diamond picks how everything translucent is drawn, and the last three steps are skipped in a frame with nothing of their kind.
The post chain in that figure is declared here and explained in
post-processing, and it is why the entity-outline target
is imported at all. Everything else after the sky draws inside the main pass,
which opens render passes of its own as it goes. With improved transparency off, which is
the default, LevelRenderer.executeClassicTransparency draws translucent
features, translucent terrain, the features that follow terrain, then clouds,
weather and the world border, all into the render pass the opaque terrain went
into. With it on, LevelRenderer.executeOit draws the same things once per
OitStage through the seven targets declared for it and composites the result
onto the main target, which is the only reason those seven are ever created.
LevelRenderer.executeOutline, LevelRenderer.executeSeeThrough and
LevelRenderer.executeAlwaysOnTop follow, each only in a frame with something
of its kind.
FrameGraphBuilder.execute is also where the profiler learns about any of
this: the level’s graph is executed with an inspector that pushes a zone per
pass, named after the pass, which is why the F3 pie chart can tell you the
sky cost you something. A graph executed without one — and there is another
kind
— reports nothing.
The graph culls passes, and culls none of these.
FrameGraphBuilder.execute keeps only the passes that transitively feed an
imported external resource and drops the rest before it orders anything. Note
the plural: it seeds from every imported resource, not from the main target
alone, which is what saves the entity-outline chain — none of its four passes
ever writes to main, and all four survive because the glow target is imported
too. With that seeding, no pass LevelRenderer declares is ever dropped in a
stock game. The outline chain is absent from a frame with no outline in it for
a different and much cheaper reason: LevelRenderer.render never adds it. The
declaration is the branch; the culling machinery is insurance against a
declaration that has become pointless, not the mechanism the game uses to turn
features off.
One bucket per buffer set, and what bucketing actually buys
LevelRenderer.prepareChunkRendersIndirect runs before the main pass is
declared, and its output is what that pass will execute — or
LevelRenderer.prepareChunkRenders does, on a device that cannot multi-draw
indirect or with Minecraft.multiDrawIndirect switched off. Either walks
LevelRenderer.visibleSections and, for each layer a section has geometry in,
computes a hash of the buffers that geometry lives in and files the draw under
that hash. Sections sharing buffers land in the same bucket. The indirect path
writes every bucket’s draws into a buffer of commands the GPU reads, and each
section’s origin into per-instance vertex data, and
ChunkSectionsToRender.DrawIndirect then issues one
RenderPass.drawIndexedIndirect per bucket, split only where a bucket holds
more draws than the device takes in one call. There the saving is the draw
calls. The fallback’s ChunkSectionsToRender.DrawSeparate issues one
RenderPass.drawMultipleIndexed per layer instead, over the draws in bucket
order, with each section’s origin arriving as a slice of a uniform buffer. That
still loops and issues one GPU draw per section, and what the bucket order
saves there is rebinding: the index buffer changes only between buckets, and on
OpenGL the vertex buffer does too.
Two draw groups over three layers, and why translucent inverts the rule
The main pass renders two groups, not three layers.
ChunkSectionLayerGroup is the coarser partition the draw side works in:
ChunkSectionLayerGroup.OPAQUE covers the solid and cutout
layers together, because neither
blends and both can be drawn in any order, and
ChunkSectionLayerGroup.TRANSLUCENT is the third layer alone, which is why
the next paragraph is only ever about that one. Turning a position back into
the section it names is LevelRenderer.viewArea, a ViewArea — and it holds
its SectionRenderDispatcher.RenderSections in a RotatingSectionStorage,
the same kind of ring the dirty flags live
in,
recentred by ViewArea.repositionCamera as you move.
The translucent layer inverts the rule whenever the order matters, which is whenever improved transparency is off: there the visit order decides the bucket, and the buffers only decide where a bucket ends. Its hash still covers the buffers, but a translucent draw can join only the bucket of the draw just before it, so each bucket is a run of neighbours sharing buffers, and a section whose buffers differ from the one before it opens a new bucket even when an older bucket has the same ones. That is what preserves the visit order across the buckets, and the visit order is what the draw list is then reversed against — the buckets and the draws inside each — so the far sections blend before the near ones. Correct blending is bought here by refusing to regroup the draws, not by refusing to share buffers. With improved transparency on, the order stops mattering and translucent sections bucket exactly as the opaque ones do.
One ordering here catches everyone out, and it is worth stating from this
side because it is a fact about where LevelRenderer.compileSections sits:
it runs after FrameGraphBuilder.execute, so terrain is drawn before the
sections queued this frame are compiled. What that costs a player, and why
the option that promises otherwise cannot deliver it, belong to section
meshing.
Translucency, re-sorted on a budget it never finishes
With improved transparency off, translucent quads inside a section have to be sorted back to front from where you are standing, and where you are standing changes constantly. Re-sorting every visible section every frame is not affordable, so the client re-sorts a slice of them each frame and lets the rest be slightly stale.
Two groups are considered. Everything in
LevelRenderer.nearbyVisibleSections — the short-radius set the frustum step
filled alongside the main list — and then a round-robin slice of
LevelRenderer.visibleSections, an eighth of it or fifteen sections,
whichever is larger, walked from
LevelRenderer.translucencyResortIterationIndex so that successive frames
continue where the last one stopped.
What actually counts as having moved
Being considered is not being re-sorted. A section is scheduled if its
TranslucencyPointOfView actually changed — and that is three integers, each
clamped to −1, 0 or +1, saying whether the camera’s section is before, level
with or past this one on that axis. Twenty-seven possible values in all,
which is why “changed” is a cheap test that is usually false: crossing a section boundary changes it only for the sections in the two slabs that boundary separates, and wandering about inside one changes it for none.
TranslucencyPointOfView.isAxisAligned is the same object answering whether
any axis reads zero — or the section is scheduled anyway if the camera’s
block position moved since LevelRenderer.lastTranslucentSortBlockPos and the
section is either axis-aligned like that or one of the nearby ones. It is then skipped
anyway if a re-sort is already scheduled for it, if it has no translucent
geometry at all, or if improved transparency is on. What runs when one is scheduled is
SectionRenderDispatcher.RenderSection.resortTransparency, which schedules a worker task that reorders the existing mesh rather than recompiling it, and which gives up without re-sorting if, when it runs, the section’s view has not changed and no axis reads zero — the cheap path section meshing
hands here.
So standing still costs nothing, walking costs a bounded amount, and a fast
enough sideways move can leave a distant pane of glass sorted for a viewpoint
you have already left.
For a 1.21-era reader. LevelRenderer.renderLevel is now
LevelRenderer.render, handed render state rather than a level. LevelRenderer.cullTerrain and LevelRenderer.applyFrustum are gone:SectionOcclusionGraphandLevelExtractor.applyFrustumdo their work. LevelRenderer.addCloudsPass, LevelRenderer.addWeatherPass and the transparency post chain are gone too:LevelRenderer.addMainPassdraws clouds and weather, and improved transparency isLevelRenderer.executeOit. Every dirty method onLevelRenderermoved toLevelExtractor.
Where to look
LevelExtractor.applyFrustum first, because stage one is the one that is not
in LevelRenderer.render; then LevelRenderer.render, which is stages two to
five in the order the first figure draws them. SectionOcclusionGraph.update for the walk, and
SectionOcclusionGraph.runPartialUpdate for the only part of it on the Render thread. LevelExtractor.extract, which decides when to call it, for why the visible
list is usually a cache. FrameGraphBuilder.execute for how declared passes are ordered and
which are dropped. LevelRenderer.prepareChunkRendersIndirect,
LevelRenderer.prepareChunkRenders and ChunkSectionsToRender for how
terrain finally reaches the GPU, with
blaze3d underneath it. The models and the atlas the terrain is
textured from are models and atlases; the block
changes that make sections dirty arrive as the packets in what the client is
told and become dirty sections in
the client level.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Section meshing
Verified against Minecraft 26.3 · Part XI · a block is placed, and the section it lives in is re-meshed, uploaded and drawn.
You right-click a block into place and within a frame or two it is simply there, with no shimmer and no fade. Behind that wait, on a client left at its default graphics preset, the Render thread itself rebuilds a cube of world sixteen blocks on a side from scratch, from a snapshot of twenty-seven sections, just after the next frame has drawn its terrain from the old mesh, and swaps the new one in before that frame ends. A block the server changes takes the slower road: a worker thread, a scratch buffer it has to queue for, and a swap that can wait a frame or more until every layer has been uploaded. All of which is why the other half of the story is the surprising one: let a block change behind you, farther back than the few blocks the visible list reaches behind the camera, and none of it happens at all. Only sections the frame is already drawing are swept for dirtiness, so the flag on a section at your back is set and then simply waits — for a second, for an hour, for as long as you keep your back turned.
The cast
| class | what it decides | thread |
|---|---|---|
LevelExtractor | which block changes dirty which sections, and which dirty sections are worth compiling this frame | Render thread |
SectionUpdateTracker | where dirtiness lives, and whether a never-compiled section is allowed to compile yet | Render thread |
RotatingSectionStorage | which slot in a fixed ring a section’s flag belongs to, and when that slot is re-homed | Render thread |
RenderRegionCache | the snapshot a mesher is allowed to read, and how much of it is shared | Render thread |
SectionRenderDispatcher | queue order, scratch buffers, the GPU arenas, and the moment a new mesh becomes the drawn one | Render thread, workers |
SectionTaskDynamicQueue | which section a free worker takes next | any |
SectionCompiler | the block-by-block walk that turns a snapshot into vertices | the worker pool, or the Render thread for a synchronous compile |
BlockModelLighter | smooth lighting and ambient occlusion, per quad, as the walk goes | the worker pool, or the Render thread for a synchronous compile |
The whole trip, in one figure
sequenceDiagram
participant CL as ClientLevel
participant LX as LevelExtractor
participant SUT as SectionUpdateTracker
participant LR as LevelRenderer
participant SRD as SectionRender<br/>Dispatcher
participant SectC as SectionCompiler
rect rgba(0, 0, 0, 0.04)
Note over CL,SectC: the client tick that handles the click
CL->>CL: setBlock, from BlockItem.placeBlock in the placement prediction
CL->>LX: setBlockDirty, which asks whether a model cares
CL->>LX: blockChanged, the player-changed bit read off the flags
LX->>SUT: setDirty over a 3x3x3 block halo, one to eight sections
end
rect rgba(0, 0, 0, 0.04)
Note over CL,SectC: the frame after that tick
LX->>LR: visibleSections, the only ones the sweep asks about
LX->>LX: RenderRegionCache takes a visible dirty section's 27-section snapshot
LX->>SUT: that section's flag cleared
LX->>LR: the swept sections, in the level's render state
alt player-changed or nearby, as PrioritizeChunkUpdates says
LR->>SRD: compileSync
SRD->>SectC: compile inline, on the Render thread
else otherwise
LR->>SRD: compileAsync
SRD->>SectC: on a worker, nearest-first, once a buffer pack is free
end
SectC-->>SRD: at most three layers, into the staging buffer
LR->>SRD: uploadTerrainBuffersToGpu, whose callback swaps in each fully uploaded mesh
end
The trip from a placed block to a drawn mesh: a tick sets flags, and the frame after it sweeps only visible sections and compiles or queues the dirty ones after its own terrain is drawn. A synchronous compile is swapped in at the end of that frame, an asynchronous one at the end of whichever frame uploads the last of it.
Read it in three beats: a change makes a flag, a frame turns some flags into work, and the end of that frame or a later one publishes the result. The middle beat is the one that leaves the Render thread, and it does not always — a synchronous rebuild compiles inline where it stands, and an empty mesh is published by whichever thread compiled it.
A click, and the flag it leaves behind
The first arrow hides who starts it. MultiPlayerGameMode starts the
prediction — the client places the block itself, and its level remembers the
state the server last confirmed, to put back if the server disagrees — but the Level.setBlock that actually changes the world happens
down inside BlockItem.placeBlock. From the renderer’s side the one
difference is a flag: your own write carries the player-changed bit and the
server’s does not. What the client is told
is the other door into the same call, by way of
ClientLevel.setServerVerifiedBlockState.
Dirtiness is a small API on LevelExtractor, and the calls differ mostly in
how much of the world they condemn.
| call | what it marks |
|---|---|
LevelExtractor.blockChanged | one changed block, with the player-changed bit read off the update flags |
LevelExtractor.setBlockDirty | one changed block, and only if ModelManager.requiresRender says a model cares |
LevelExtractor.setBlocksDirty | a box of block positions |
LevelExtractor.setSectionDirty | one section, by section coordinate |
LevelExtractor.setSectionDirtyWithNeighbors | that section and the ones touching it |
LevelExtractor.setSectionRangeDirty | a range of sections |
LevelExtractor.allChanged | everything at once |
Twenty-seven block positions make the halo a single block change marks,
and they are positions, not sections. This is the number to keep straight. A 3×3×3
neighbourhood of blocks maps to exactly one section for any block that is
not on a section boundary, and to at most eight when it is — a corner block
touching seven neighbours plus its own. Only the mesher’s read region,
much later on, is genuinely twenty-seven sections. There is a gate on one
of the two doors — ModelManager.requiresRender guards
LevelExtractor.setBlockDirty, so a state change no model reacts to marks
nothing through that route — but it cannot stop a placed block.
Level.setBlock goes through both doors, and the second,
LevelExtractor.blockChanged, marks the halo whatever the models say.
The flag belongs to a slot, not to a section
SectionUpdateTracker holds a SectionUpdateTracker.SectionDirtyState per
section — with SectionUpdateTracker.SectionDirtyState.isDirty and
SectionUpdateTracker.SectionDirtyState.isDirtyFromPlayer, the second being
what the prioritise chunk updates setting keys off further down this page —
and the tracker is built on RotatingSectionStorage, a fixed ring of slots that
RotatingSectionStorage.repositionCenter re-homes as the camera moves. A
dirty flag therefore belongs to a slot, not to a section: walk far enough
away and the slot is re-homed and the flag is gone. Nothing is lost by that,
because a newly homed slot starts dirty.
The sweep that only looks at what you can see
Once a frame, the extract pass walks the sections the renderer considers visible and collects the dirty ones. That is the whole mechanism behind the hook: a section that is not in the visible set is never even asked whether it is dirty, so its flag sits there, indefinitely, and the section rebuilds the instant it comes back into view. Which sections count as visible, and the reachability walk that decides it, belong to visibility and the frame graph.
One extra gate applies, and only to sections that have never been compiled
before. SectionUpdateTracker.hasAllNeighbors requires the eight surrounding
chunk columns — horizontally only, never the section’s own column — to be
loaded and lit before a first compile is allowed. A mesher decides whether a
block’s face is worth drawing by looking at the block on the other side of it,
so a section built without its neighbours would be a section built against
nothing. A recompile skips that check entirely: a section that has a mesh
already may always build a new one.
What a mesher is allowed to read
A compile on a worker runs for an unbounded time while the Render thread
keeps applying block updates, so it cannot be allowed near the live world.
RenderRegionCache builds it a RenderSectionRegion instead: a 3×3×3 grid
of SectionCopy, each holding a genuine copy of one section’s
PalettedContainer together with an immutable map of that chunk’s block
entities. Inside the compile, a block state read is a read of that copy, and
it will answer the same way from the first quad to the last however much the
world has moved on.
The cache is what stops this being ruinous. Twenty-seven copies per dirty
section would mean 27n copies for n sections, and neighbouring dirty
sections share almost all of their neighbourhood — so the regions built in
one extract share their SectionCopy instances, and the real cost is far
closer to n.
What is not copied is as informative as what is. Tints and light are read
live, through the region’s references to ClientLevel and the light engine —
so for those two the mesher is looking at the world as it is when it asks, not
at the world as it was when the snapshot was taken.
The queue, and the scratch buffer every compile must hold
SectionRenderDispatcher takes the sections the extract collected and either
queues them (SectionRenderDispatcher.RenderSection.compileAsync) or compiles
them on the spot (SectionRenderDispatcher.RenderSection.compileSync). The
dispatcher does not decide which: LevelRenderer.compileSections does, from
the snapshotted prioritise chunk updates option, and this is where
SectionUpdateTracker.SectionDirtyState.isDirtyFromPlayer is finally spent.
PrioritizeChunkUpdates.NONE — the option’s own default, which the fancy preset
a new client starts on replaces — never compiles inline;
PrioritizeChunkUpdates.PLAYER_AFFECTED does so for a section the player
changed; PrioritizeChunkUpdates.NEARBY does so for those and for anything
close to the camera. SectionTaskDynamicQueue
decides the order and it is nearest-first, with one guard: a recompile only
beats a first-time compile while a small quota lasts, and only if it is also
nearer. With no first-time compile queued at all, a recompile wins outright.
So terrain you have never seen is never starved by a stream of rebuilds to
terrain you have.
A thread is not enough. Each task-runner must also acquire a
SectionBufferBuilderPack — the scratch vertex buffers a compile writes into
— from SectionBufferBuilderPool before it can do anything, and the pool is
sized to the processor count or to a share of the heap, whichever is
smaller, degrading further if it hits an out-of-memory error while
allocating. A worker that cannot get a pack puts its task back on the queue
and gives up its turn — the task is requeued, not the flag, which was
cleared when the work was taken, so the only symptom of an exhausted pool is
terrain arriving more slowly.
That requeue is a null check, and it is worth knowing how wide it is: the catch that implements it covers the whole compile. A failure while one block is being tesselated is reported, as a crash; a null-pointer failure anywhere else in the compile quietly requeues the section, and the pack it had acquired is never given back. A section that fails that way every time takes one more pack from the pool each time a worker tries it, until the pool is empty and no compile on a worker ever finishes again.
The ceiling on how many compiles run at once is therefore the pool plus
one: the synchronous path uses RenderBuffers.fixedBufferPack, which is not
in the pool at all. And because the pool is usually larger than the
background pool, the constraint you actually hit is normally the thread
count after all.
What the compiler makes
SectionCompiler.compile walks every block in the section, asks the models
for its quads and sorts them into layers. The asking is
ModelBlockRenderer.tesselateBlock, one instance built per compile from the
ambient-occlusion option and BlockColors, and the quads come back through a
BlockQuadOutput — one of two callbacks the compiler builds per compile and
picks between per block, so the renderer never knows which layer’s buffer it
is writing into. Its product is
SectionCompiler.Results, four things at once:
SectionCompiler.Results.renderedLayers (the geometry, per layer), the
SectionCompiler.Results.blockEntities it found on the way, a
SectionCompiler.Results.visibilitySet for the reachability walk on the
other page, and SectionCompiler.Results.transparencyState, the sort state
for the translucent layer. Those become the section’s CompiledSectionMesh.
Lighting is not a separate stage: BlockModelLighter computes smooth
lighting and ambient occlusion as the walk goes, with a thread-local cache
bracketed around each compile.
Three chunk section layers exist, and only three:
ChunkSectionLayer.SOLID, ChunkSectionLayer.CUTOUT and
ChunkSectionLayer.TRANSLUCENT. A quad’s layer is normally decided at bake
time and simply carried into the compile — see models and
atlases for the BlockStateModelSet the compiler
reads and the reload that invalidates every mesh in the world. But the mesher
overrules it in one place, and fluids take a road of their own. Every leaf quad is redirected to
ChunkSectionLayer.SOLID when the cutout leaves option is off — that is
ModelBlockRenderer.forceOpaque, tested per block, and it picks which of the
compiler’s two callbacks the renderer is handed — so the setting is baked
into the mesh rather than applied at draw time. And fluids never consult a
baked quad at all: FluidRenderer.tesselate is a separate call with its own
callback, and the layer comes from the FluidModel.
Why “prioritise chunk updates” still costs you a frame
The figure’s caption says it and it is worth stating plainly, because it
is the one ordering fact on this page a player can feel:
LevelRenderer.compileSections runs after FrameGraphBuilder.execute, so
terrain is drawn before the sections queued this frame are
compiled.
The strongest promise the setting can therefore make is that the mesh exists
by the end of frame N; it appears in frame N+1. The setting buys you the
compile, not the draw — and it buys it by doing the work on the Render thread,
which is why it can cost you that frame outright.
Onto the GPU, and a swap that is late on purpose
The worker does not touch the GPU. It appends its vertices to a
StagingBuffer under a lock, spinning if the buffer is full — a real
back-pressure point, where a worker waits on the Render thread rather than the
buffer growing to fit it. The Render thread drains it in
SectionRenderDispatcher.uploadTerrainBuffersToGpu, and each completed
upload fires the callback that publishes the new mesh. The result of a
compile therefore arrives back on the Render thread, always, with exactly one
exception: a section that compiled to no geometry is published directly by
the thread that compiled it, because there is nothing to upload.
The destination is not one buffer per layer but an arena per layer. Each
ChunkSectionLayer has an UberGpuBuffer pair owning a growing list of
fixed-size heaps — 128 MiB for vertices, 32 MiB for indices — and each heap
is a real GPU buffer sub-allocated by a TlsfAllocator, freed again when it
empties. Sections are tenants in a shared allocation, not owners of buffers;
blaze3d is where those buffers come from.
The swap is atomic and late, on purpose
And now the second fact this page exists to place. SectionRenderDispatcher.RenderSection.sectionMesh keeps pointing at
the old mesh until every layer’s vertex and index buffer has reported
uploaded. There is no frame in which a rebuilt section is missing, no flicker
and no hole — the price being that the section you can see is, for a frame
or more, deliberately out of date. SectionRenderDispatcher.RenderSection.reset
is the other end of that lifecycle, and
SectionRenderDispatcher.RenderSection.getVisibility is not part of it at
all despite the name — it is a fade, an alpha that climbs from nothing to one
over the fade duration. That fade is why arriving terrain appears softly
and a block you place never does: every section fades in over the one
Options.chunkSectionFadeInTime setting, near or far, and the clock starts
at a section’s first upload — so a recompile of terrain you have been staring at,
which is what placing a block is, has no fade left to spend.
SectionRenderDispatcher.RenderSection.resortTransparency is the cheap path
that reorders an existing translucent mesh without recompiling anything;
visibility and the frame graph owns the
budget that decides when it runs.
For a 1.21-era reader. The whole dirty API moved: every setBlockDirty-shaped method on
LevelRendereris now onLevelExtractor, in client/renderer/extract, and the flags live inSectionUpdateTracker. LiquidBlockRenderer is nowFluidRenderer; BlockRenderDispatcher is gone andModelBlockRendererdoes its tesselating; BlockAndTintGetter.getShade is gone, andBlockAndTintGetter.cardinalLightinganswers with aCardinalLighting. There are three chunk section layers, not four: ChunkSectionLayer.TRIPWIRE is gone.
Where to look
LevelExtractor.blockChanged and LevelExtractor.setBlockDirty for where a
change becomes a flag, and SectionUpdateTracker for where the flag lives.
SectionUpdateTracker.hasAllNeighbors for the gate on a first compile.
RenderRegionCache and SectionCopy for what a mesher may read.
SectionRenderDispatcher.RenderSection.compileAsync and
SectionTaskDynamicQueue for what gets built and in what order.
SectionCompiler.compile for the walk itself, and BlockModelLighter for
where ambient occlusion comes from.
SectionRenderDispatcher.uploadTerrainBuffersToGpu for the upload and the
swap. Then visibility and the frame
graph for who decided the section was
visible in the first place, and the client
level for the world all of this is reading.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Models and atlases
Verified against Minecraft 26.3 · Part XI · a resource pack changes one texture, and every stone block in the world redraws.
You drop a pack into the folder, move it above the default, and the screen goes to the loading overlay for a second. The pack replaced exactly one file, a single stone texture, and when the overlay clears every block you can see has been thrown away and rebuilt — not just the stone. The models for stone were byte-identical before and after, and were still re-listed, re-parsed, re-resolved and re-baked: nothing in the reload asks what changed, and the sprite they point at is now a different object in a different image. Nothing here is incremental. The unit of change is the model layer.
Every block face, every item sprite and every particle texture starts as a
PNG in a pack, and most of them as JSON too, and ends as four vertices pointing at a rectangle
of a stitched atlas. The stages below are that journey, and they happen
inside a resource reload — the resource
system owns ReloadableResourceManager
and the barrier this rides on — and at no other time.
The cast
| class | what it decides | thread |
|---|---|---|
ModelManager | the reload’s spine, and what the finished lookup sets contain | the Render thread to start and to apply, workers between |
AtlasManager | which atlases exist, and it publishes their stitches for others to await | Render thread for the handshake, workers for the work |
SpriteLoader | how one atlas is decoded, packed and mipmapped | workers |
ModelDiscovery | which file an Identifier means, and what its parent chain resolved to | one worker task |
ModelBakery | which unbaked model each BlockState and each item file bakes to | workers, in batches |
FaceBakery | a quad’s positions and UVs, both rotated, and its chunk layer | workers |
TextureManager | who owns each AbstractTexture, and when an animation advances | Render thread |
ItemModelResolver | which ItemModel a given ItemStack draws with | Render thread, every frame |
The shape of the work: seventeen fans, one barrier
This is the clearest fan-out-and-barrier in the client. Seventeen independent
pieces of work start on worker threads at once, and the figure below draws
all of them, each set in two boxes: the twelve atlas stitches
AtlasManager.reload schedules, and the five roots ModelManager.reload
opens. Three of those five
are directory listings that are each themselves a fan of one task per file.
The bake waits on only two of the twelve stitches, and everything meets at the
barrier.
flowchart TD
RL["a reload starts: F3+T, a pack change, or the game booting"]
HS["Render thread: AtlasManager.prepareSharedState, then every listener's reload called"]
subgraph W["on worker threads"]
subgraph AT["the atlas stitches"]
S1["the blocks and items atlases"]
S2["the other ten atlases"]
end
subgraph MR["the five model roots"]
LST["three listings, models/, blockstates/ and items/, one task per file"]
ONE["EntityModelSet.vanilla and BuiltInBlockModels.createBlockModels"]
end
RES["ModelDiscovery resolves what the three listings name"]
BAKE["the bakes: ModelBakery.bakeModels, then the BlockModel layer and fluids"]
end
BAR["the barrier"]
UP["Render thread: TextureAtlas.upload, then ModelManager.apply"]
INV["LevelExtractor.allChanged raises a flag the next frame reads"]
RL --> HS
HS --> AT
HS --> MR
LST --> RES
S1 & RES & ONE --> BAKE
BAKE & S2 --> BAR
BAR --> UP --> INV
The reload as a fan and its joins: the Render thread publishes the pending stitches and calls each listener’s reload, and what those schedule runs on workers, the bake waiting on two atlases and all five roots. Everything meets at the barrier, and what follows it runs on the Render thread in one order.
Read it as spread, converge, upload, invalidate: inside the outer box, worker threads in whatever order the joins allow; below the barrier, the Render thread in exactly one.
Twelve atlases and three listings, all at once
In: every enabled pack. Out: twelve stitched images, three parsed maps.
AtlasManager owns AtlasManager.KNOWN_ATLASES — twelve of them, named
in AtlasIds — and each is a definition file in atlases/, read from every
pack that ships one, not a folder scan. SpriteSourceList runs that file’s five kinds of source in order —
SingleFile, DirectoryLister, SourceFilter, Unstitcher and
PalettedPermutations, registered in SpriteSources — and a later source
overwrites an earlier one by id, which is how a pack’s own definition file
redirects a sprite; replacing a vanilla texture needs none of that, since the
pack’s PNG shadows vanilla’s under the same name. SpriteLoader.loadAndStitch decodes and reads metadata one
task per sprite, hands the results to Stitcher — which sorts by height
and grows by powers of two — and returns a SpriteLoader.Preparations,
after which MipmapGenerator builds the mip chain under whatever
MipmapStrategy the texture’s own metadata asks for.
Two properties of that packing surprise people, and a third setting rides on
both of them. The mip level is clamped by
the lowest set bit of each sprite’s width and height, with a warning, so one
undersized or oddly sized texture degrades mipmapping for every sprite in the
atlas — and only the
block atlas asks for mipmaps, the other eleven stitch flat. And sprite
padding derives from the mip level and the anisotropic filtering setting,
with the UVs computed inside the padded box, so anisotropy changes the
layout and every UV in the game. The mipmap slider is blunter still: it
writes the new level onto AtlasManager and asks for a full resource reload
when the video settings close, so every sprite in the game is decoded and
stitched again.
Meanwhile BlockStateModelLoader parses blockstates/ into a
BlockStateModel.UnbakedRoot per BlockState, through
BlockStateModelDispatcher and VariantSelector, and ClientItemInfoLoader
parses items/ into ClientItem.
The handshake that lets listeners share a future
Reload listeners are not supposed to reach into each other, and these have
to: baking cannot resolve a texture slot without knowing where the
sprite landed. The channel that lets them is the resource system’s
shared-state
pass, and
AtlasManager.PENDING_STITCH is the only key the game declares. What
matters here is which end of it this page is: AtlasManager publishes a
pending stitch for all twelve atlases, and ModelManager awaits exactly
the two it bakes against — the blocks atlas and the items atlas — from
inside its own prepare, which lets loading and resolving the models overlap
the stitching even though the bake waits for it. ParticleResources awaits
the particles atlas the same way.
Interning the models, and dropping the ones that loop
In: the raw models/ map. Out: a ResolvedModel per Identifier.
ModelDiscovery is a single worker task and the narrow waist of the
pipeline. Every Identifier becomes one ModelDiscovery.ModelWrapper,
caching its resolved texture slots and its baked geometry per ModelState,
and ModelDiscovery.resolve returns the map of ResolvedModel, whose
helpers walk the parent chain for geometry, slots, ambient occlusion and
transforms. A model whose parent chain loops is logged and excluded, one whose parent
is missing inherits from the missing model,
and a model nothing references is parsed and never baked. In parallel,
ModelGroupCollector gives each block state a visual-equality group, which
is what lets ModelManager.requiresRender later say that change is
invisible: two states sharing a group id cost nothing to draw when one
becomes the other, unless their fluid state differs.
The JSON model itself is CuboidModel, made of CuboidModelElement,
CuboidFace and CuboidRotation, with UnbakedGeometry and
UnbakedCuboidGeometry between a resolved model and its quads, and
Material, SpriteId, TextureSlots and MaterialBaker are the
indirection from a model’s slot reference to a real sprite.
One bake per block state, and why that is affordable
In: resolved models and the stitched sprites. Out: a ModelBakery.BakingResult.
ModelBakery.bakeModels bakes once per block state and once per item
model file, sharing one baker whose caches are concurrent maps, and the
bakes are batched rather than scheduled individually, so this is at most
sixteen tasks per worker thread for each of the two maps, and not tens of
thousands. ModelBakery.MissingModels — a block
part, a block, an item and a fluid — is baked first of all, so there is
always something to substitute. Two further bakes follow: the BlockModel
display layer, including the hard-coded BuiltInBlockModels, and the
FluidStateModelSet.
That name is the trap this page owes you, and it is worth taking now rather
than later. BlockStateModel and BlockModel are different interfaces in
different packages. BlockStateModel is the quad source, the thing a
mesher reads. BlockModel is the display model, the thing an entity
renderer reads, and the tints and the transform a reader expects on the
interface belong to its usual implementation, BlockStateModelWrapper,
rather than to the interface itself. Two block tables come out of a bake
because there are two interfaces, and block-entity
rendering
explains which is which.
Dedup is what makes per-state baking cheap. Every state sharing an unbaked
variant gets the same baked object, geometry is cached per ModelState,
and vertex positions and material infos are interned. Multipart is the
exception that proves it: each state gets its own thin MultiPartModel
over a shared MultiPartModel.SharedBakedState, so every fence, wall, pane
and redstone-wire state really is a distinct object.
The output is a QuadCollection of BakedQuad — a ten-component record
of four positions, four packed UVs, a Direction recomputed from the baked
vertices, and a BakedQuad.MaterialInfo holding the sprite, the
ChunkSectionLayer, the item RenderType and its two glint variants, the tint index, shade and
light emission. Three things are decided here, and one of them is to put a thing off. Cull faces are rotated
at bake time — a rotated variant’s north-culled quad is filed under the
rotated direction, in UnbakedCuboidGeometry, while FaceBakery rotates the
quad’s vertices and UVs and applies the uvlock — so the mesher never thinks about
it. Tint is not resolved: the quad carries an index, and the colour
arrives later from BlockColors or an ItemTintSource. And
QuadCollection carries translucent and animated flags, OR-ed up through
every model wrapper, so the code that handles a block’s model outside the terrain mesh — the block
outline, moving blocks, the breaking overlay, the display and item models —
can decide whether they need sorting or re-uploading without reading a quad.
A quad’s chunk layer is read out of the sprite’s pixels
The most surprising decision in the pipeline is the one nobody has to make.
FaceBakery.bakeQuad does not take the render layer from the block or from
any per-face declaration: it asks SpriteContents what transparency
exists inside that quad’s UV rectangle. The answer is a Transparency, two
booleans wide — are there fully transparent pixels in there, and are there
partly transparent ones — and ChunkSectionLayer.byTransparency reads them the
other way round: any partial alpha makes it translucent, otherwise any full
transparency makes it cutout, otherwise solid. The item render type is picked here
too, out of Sheets, by the partial alpha alone and by whether the sprite is on
the blocks atlas.
One pixel — enough alpha inside a quad’s UV rectangle to move that face
out of the solid layer (FaceBakery, asking SpriteContents).
A pack author who softens one edge of a texture has changed which chunk
layer that face draws in, and so when it is sorted and how it blends with
everything behind it. There is no warning, and only one way out: a
Material may set Material.forceTranslucent, from a force_translucent
key beside the sprite name, which skips the scan and takes the translucent
layer whatever the pixels say. A hundred and ten of the shipped models use
it — the stained glass, mostly — and it is the exception that shows the
rule, because it exists for textures whose alpha the scan would read
correctly and whose author wants them sorted anyway.
Every layer fails soft, and the two that do not
Missing and malformed input is a warning and a substitution at every layer of the pipeline above, which is why a broken pack usually produces checkerboards rather than a crash report.
| what goes wrong | what you get instead |
|---|---|
| a model file will not parse | that model alone is dropped |
| a blockstate file will not parse | that pack’s copy is skipped and a lower pack’s used; with none left, the block’s states are dropped |
| one variant selector is malformed | that selector alone is dropped |
| a model’s parent is missing | the missing model stands in as its parent |
| the parent chain is a cycle | the model is logged and excluded |
| a bake throws | ModelBakery.MissingModels, baked first for exactly this |
| a sprite id belongs to no atlas | the MissingTextureAtlasSprite checkerboard |
| a texture slot is unbound, or its chain does not resolve | the same |
| a block model draws from outside the block atlas | the model is refused and the missing model substituted |
| a block state has no entry at all | the missing model — the broadest of them, and the last |
The atlas-membership case is caught only at the moment a model is baked, and
quietly. Minecraft.selfTest, the startup sweep that throws rather than
warns, only runs in a development environment, and it looks for states
that got the missing model, not at atlases.
Two places have no soft path. If Stitcher cannot grow an atlas within the
device’s maximum texture size it raises StitcherException, which becomes
a crash report listing every sprite, and mip generation is the second hard
crash site. A pack with too many textures does not degrade — it crashes.
The barrier, and how a sprite reaches the GPU
In: everything above. Out: live lookup sets and live GPU textures.
The Render thread started the reload — every listener’s shared state,
AtlasManager.prepareSharedState among them, then every listener’s reload,
which scheduled the work — and now it does the rest. TextureAtlas.upload builds
the new texture and closes the old sprites, and ModelManager.apply
assigns the new lookup sets in one go. Underneath sits TextureManager, a
PreparableReloadListener owning every AbstractTexture by Identifier —
each atlas included — plus the TickableTextures it advances, and it
registers a checkerboard texture at MissingTextureAtlasSprite’s location at
construction.
The GPU side belongs to blaze3d.
How a sprite’s pixels reach the atlas depends on whether it moves. A static
sprite goes into a throwaway scratch texture, is blitted into every mip
level by a render pass, and the scratch texture is closed. An animated
sprite instead keeps one persistent texture per unique frame for the life
of the atlas and is redrawn only when it has something new to show, through
a second pipeline entirely if the animation interpolates —
SpriteContents.AnimationState drives it and
TextureAtlas.cycleAnimationFrames steps it.
The flag that rebuilds the world
In: a successful reload. Out: every visible section re-meshed, a frame later.
Nothing rebuilds during the reload. LevelExtractor.allChanged raises a
flag, and on the next frame’s extract
LevelRenderer.invalidateCompiledGeometry builds a new SectionCompiler
from the new model sets and replaces every section with a fresh, uncompiled
one, each re-meshed as it is found visible — see
section meshing. SectionCompiler is the biggest
consumer of BlockStateModelSet.get but far from the only one: the
block-breaking overlay, moving blocks, the in-wall screen effect, the
nether-portal sprite on the loading screen, TerrainParticle and
BlockMarker all read it too, with the HUD, LevelExtractor,
FeatureRenderDispatcher and the display table’s own fallback covering the
rest, bar the development-only Minecraft.selfTest. The path from a set to triangles is
BlockStateModel.collectParts then BlockStateModelPart.getQuads, over
SingleVariant, WeightedVariants or MultiPartModel with
SimpleModelWrapper as the usual concrete part. None of it crosses the
network: the server has no idea what a model is.
How an item picks its model
An item never asks for a model by name. An ItemStack names its model in
DataComponents.ITEM_MODEL — an Identifier, nothing else, and a stack
without one draws nothing — and that id is the whole decision: a stack whose component says diamond sword renders
as a diamond sword whatever item it actually is.
ItemModelResolver — held by Minecraft, called by the entity, block
entity and GUI renderers as they extract a frame — reads the component,
asks ModelManager.getItemModel for the baked model and
ModelManager.getItemProperties for the flags that travel with it, then
calls ItemModel.update, which appends the layers it draws to an
ItemStackRenderState. Both lookups fall back rather than fail: an unknown
id quietly yields the missing item model and the default
ClientItem.Properties, whose two flags are whether the item plays
the hand-swap animation and whether it may overflow its slot in the GUI, beside a scale for the swap
animation.
What each id maps to came from items/, parsed into ClientItem by
ClientItemInfoLoader and baked alongside the block models. ItemModels
registers eight kinds of unbaked item model and only one of them,
CuboidItemModelWrapper, draws quads of its own. EmptyModel draws
nothing, CompositeModel stacks the others, SpecialModelWrapper hands the
layer to a renderer instead of quads, BundleSelectedItemSpecialRenderer is
a bundle’s peek — and the remaining three, RangeSelectItemModel,
SelectItemModel and ConditionalItemModel, do not draw at all. They
branch. SpecialModelRenderers covers the thirteen shapes no cuboid model
can express, from chests and banners to shields, heads, tridents and
decorated pots (block-entity
rendering
is what eleven of those thirteen borrow from). ItemModelGenerator is the odd
one out and by far the most-used model in the game: it extrudes a flat sprite
into geometry by tracing its alpha channel, which is what item/generated
means.
What the three branching models branch on
The three are the whole of how an item’s appearance changes without the item
changing, and each reads from its own registry of properties under
client/renderer/item/properties. There are thirty-three of them in three
shapes, and the shape decides which model can use it.
| the model | what the property returns | how many | examples |
|---|---|---|---|
RangeSelectItemModel | a float, thresholded against declared cut-offs | 10, in RangeSelectItemModelProperties | CompassAngle, Damage, Cooldown, CrossbowPull, BundleFullness, UseDuration |
SelectItemModel | a value matched against declared cases | 10, in SelectItemModelProperties | MainHand, DisplayContext, TrimMaterialProperty, ItemBlockState, LocalTime, ContextDimension |
ConditionalItemModel | a boolean, choosing one of two models | 13, in ConditionalItemModelProperties | Damaged, Broken, IsUsingItem, IsSelected, IsCarried, FishingRodCast, IsKeybindDown |
Three of them keep state, which nothing else in the baking pipeline does. A
compass and a clock each run a NeedleDirectionHelper, a damped follower
updated once per tick for the baked model rather than per stack, and a
compass that has a target damps only when its owner is the local player — which is the wobble, and a
model may declare wobble: false and get the raw angle instead. LocalTime is
the third, and it re-reads the wall
clock at most once a second, which is why a chest item notices Christmas
and a placed
chest does
not.
DisplayContext is the row worth knowing for a different reason: it is what
lets one item file draw one thing in your hand, another in a frame and a
third in the GUI, and the context comes from whoever is drawing rather than
from the stack.
One rule is enforced here. A block model may only use the block atlas, and a cuboid item model must draw every quad from a single atlas — items or blocks, not both. A model that mixes them throws, the exception is caught upstream, and the item falls back to the missing model. Where the stack itself comes from is items and stacks.
Questions players ask
Why does lag slow the water down but the pause menu not stop it? Because
animations do not advance once per tick. TextureManager.tick sits outside
the client’s catch-up tick loop, so a laggy client advances every animation
by one tick however many ticks it just owed. Pausing does not touch it —
DeltaTracker keeps handing out ticks with the menu open — and the one thing
that does stop the water is /tick freeze, because the guard on that call, beside a tick being owed, is
Minecraft.isLevelRunningNormally.
Can I look at the atlas? Yes, and it is the fastest way to understand
this page. Options.keyDebugDumpDynamicTextures runs
TextureManager.dumpAllSheets, which writes every atlas in the game to disk
under the game directory with a text listing of each sprite’s position and
size beside it. A shared-constant flag,
SharedConstants.DEBUG_DUMP_TEXTURE_ATLAS, makes every atlas dump itself at
each upload too, without the keypress.
For a 1.21-era reader. BlockModelShaper is now
BlockStateModelSet; BlockElement and BlockElementFace areCuboidModelElementandCuboidFace; BlockModelDefinition isBlockStateModelDispatcher; and BlockRenderDispatcher and ItemRenderer are gone with no successor of that name. One trap:BlockModelstill exists and now means something entirely different — the display model above, where the JSON one isCuboidModel.
Where to look
ModelManager.reload for the shape of the pipeline, then
AtlasManager.prepareSharedState for the handshake that makes it parallel.
ModelDiscovery for resolution, ModelBakery.bakeModels for baking,
SpriteLoader.loadAndStitch and Stitcher for the atlas,
FaceBakery.bakeQuad for where a face’s layer is decided, and
ItemModelResolver and BlockStateModelSet.get for the two ways out.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Entity rendering
Verified against Minecraft 26.3 · Part XI · a zombie is drawn: extract, submit, prepare, execute.
A zombie shuffles out of the dark towards you, you hit it, and for a moment it
flashes red. Between that zombie and those pixels stand four stages, each
handing the next what the stage before made of it, never the live mob: the live mob is read
into a fresh render state, the state is described as things that ought to be
drawn, the descriptions are grouped and the translucent ones sorted, and only then
does anything write a vertex. Which is why EntityRenderer has no render
method — nothing on this page draws until the last stage — and why the zombie is posed at least twice
in the frame you are looking at, three times if it is glowing, an
arrangement that is only sound because Model.setupAnim resets every part to
its baked pose before it starts.
All four stages are one thread, inside one Minecraft.renderFrame. The split
is a discipline, not a threading boundary: the first stage touches live
entities, the other three only snapshots. Nothing here reads the network —
everything drawn is a redrawing of what the server already told the client
(what the client is told,
synched entity data).
The cast
| class | what it decides | thread |
|---|---|---|
LevelExtractor | which entities are visible at all, and when their states are built | Render thread |
EntityRenderDispatcher | which EntityRenderer an entity gets | Render thread |
EntityRenderer | what goes into the render state, and what gets submitted — it never draws | Render thread |
EntityRenderState | one entity’s whole frame, copied by value, holding no Entity and no Level | a value object |
RenderLayer | the extras — armour, held items, eyes, capes — each submitting at its own order | Render thread |
SubmitNodeStorage | the submit nodes, bucketed by a global draw order | Render thread |
FeatureRenderDispatcher | the grouping, the batching, and where the vertices are finally written | Render thread |
ModelFeatureRenderer | the one feature renderer that walks a ModelPart tree | Render thread |
Four stages, and what each hands the next
flowchart TD
subgraph EX["GameRenderer.extract"]
E["Extract: LevelExtractor gets one fresh render state per visible entity"]
end
subgraph RE["LevelRenderer.render"]
S["Submit: each renderer describes what should be drawn"]
P["Prepare: FeatureRenderDispatcher groups, sorts the translucent, writes vertices"]
X["Execute: the frame graph's passes issue the draws"]
end
E -- "value objects, no Entity, no Level" --> S
S -- "submit nodes, bucketed by order" --> P
P -- "one prepared frame" --> X
The four stages and what each hands the next, the first inside the extract and the other three inside the level’s render. Only the box at the top reads a live entity: everything below it works from the copy.
The worked instance is one zombie, traced through extract, submit and prepare in the sections below.
Extract: the live entity becomes a snapshot
In: ClientLevel.entitiesForRendering, and a frustum.
Out: one freshly allocated render state per surviving entity.
Decided: visibility, and everything the other three stages will ever know.
LevelExtractor.extractVisibleEntities walks the client level’s entity
list, drops what
LevelExtractor.isEntityVisible rejects, and hands each survivor to
EntityRenderDispatcher.extractEntity. That call finds the renderer for the
entity’s EntityType through EntityRenderDispatcher.getRenderer — a map
the dispatcher rebuilds on every resource reload from EntityRenderers, the static table that
files one EntityRendererProvider per type and is the reason a renderer is
shared rather than per-entity — allocates a state with
EntityRenderer.createRenderState, fills it by running
EntityRenderer.extractRenderState down the whole inheritance chain, and then
lets EntityRenderer.finalizeRenderState reach into the world one last time
to sample the shadow.
Visibility is three tests in two places. EntityRenderer.shouldRender
runs two of them, in this order: first Entity.shouldRender (which is the
distance test, its limit scaling with the entity’s own bounding box), and
only then the frustum. The third — “is the section this entity stands in
compiled and faded at least a third of the way in” — belongs to LevelExtractor, which asks LevelRenderer
to look the section up: a question the reachability walk
never answers, since an entity is not tested against its list. Block entities keep the distance half
and meet the frustum only with their section, the few drawn off screen not at all, which is the first difference
block-entity rendering
makes. Several things escape the frustum entirely: anything indirectly carrying
the local player, the three renderers that declare themselves unculled, a
Display that sets its own no-culling flag — DisplayRenderer is large for the
same reason the entity is unusual: one abstract
base carrying the interpolation and billboarding every display shares, with a
nested renderer each for a block, an item and a line of text — and, the ones
nobody expects,
an entity on the other end of a visible leash, an end crystal with a beam
target and a guardian firing one, each of which is drawn for what it is attached
to: the leash and the beam when they are in view, the crystal whenever it has a
target at all.
One zombie through this stage, from the three tests to a finished state:
sequenceDiagram
participant LX as LevelExtractor
participant ERD as EntityRender<br/>Dispatcher
participant ZR as ZombieRenderer
LX->>ERD: shouldRender, from LevelExtractor.isEntityVisible
ERD->>ZR: shouldRender — Entity.shouldRender's distance, then the frustum
LX->>LX: the third test — is its section compiled and visible?
LX->>ERD: extractEntity, at the zombie's own partial tick
ERD->>ZR: createRenderState, with the zombie and that partial tick
ZR->>ZR: a fresh ZombieRenderState, filled down the whole chain
ZR->>ZR: finalizeRenderState samples the shadow from the blocks below
Extract for one zombie: two of the three visibility tests are the renderer’s,
and the extractor asks the third of LevelRenderer. Everything after them
happens inside the renderer, which builds a new state object and fills it.
Light is not read at draw time. It comes from
EntityRenderer.getPackedLightCoords during extract — the dispatcher has a
method of the same name, and nothing calls it — and
that method returns full block light for a burning entity. Shadows are sampled here too,
and never past sixteen blocks: the renderer walks the blocks under the entity,
computes an alpha for each, and stores the shapes and alphas in the state, so
that the feature renderer two stages later only has to turn them into quads.
The strength falls to nothing at sixteen blocks, so a distant mob has no
shadow at any settings, and an invisible entity skips the sampling entirely.
The ladder of render states
The tree is a ladder, each rung adding what the rung below could not assume.
EntityRenderState is the floor: position, EntityRenderState.ageInTicks,
EntityRenderState.lightCoords, EntityRenderState.outlineColor,
EntityRenderState.nameTag, EntityRenderState.leashStates and
EntityRenderState.shadowPieces — as true of a dropped item as of a wither.
LivingEntityRenderState adds rotations,
LivingEntityRenderState.walkAnimationPos,
LivingEntityRenderState.deathTime, LivingEntityRenderState.isBaby and
LivingEntityRenderState.hasRedOverlay. ArmedEntityRenderState adds hands,
HumanoidRenderState a pose and equipment, UndeadRenderState one overridden rule,
which the zombies share with the illagers and not the skeletons, ZombieRenderState two flags of its own. The player’s
AvatarRenderState branches off the humanoid rung beside them.
Nothing in any of them holds an Entity or a Level — verified across every
class in the tree. The one member that looks live, an AnimationState on the
eleven states that carry one, is a single-int tick counter copied by value,
not a handle back into the world. The one real handle is a falling block’s: its
moving-block state carries the level’s light engine, which lights it at prepare.
The red flash is not a colour
It starts as LivingEntity.hurtTime — or LivingEntity.deathTime — and
becomes the boolean LivingEntityRenderState.hasRedOverlay here, at extract.
At submit, LivingEntityRenderer.getOverlayCoords packs that boolean into an
OverlayTexture coordinate alongside a separate white-flash axis, the one
the creeper’s fuse uses — and, besides the creeper, only primed TNT, a primed TNT minecart and the sulfur cube’s inner layer. The wither is not on that list: it flashes
by swapping to a second texture. That packed integer rides through the submit node
untouched and lands, at prepare, as a per-vertex attribute. Nothing along
the way is ever tinted red.
Submit: describing a draw without making one
In: the render states, the camera, and a shared PoseStack.
Out: submit nodes in SubmitNodeStorage, bucketed by order.
Decided: what should be drawn, and where in the world’s draw order.
LevelRenderer.submitEntities walks the states and calls
EntityRenderDispatcher.submit for each, after
EntityRenderDispatcher.prepare has set the camera for the frame.
LivingEntityRenderer.submit then describes the body, poses the model, and
lets every RenderLayer describe its own extra through RenderLayer.submit —
in that order, because the pose is only needed by the layers. One zombie
through this stage:
sequenceDiagram
participant LR as LevelRenderer
participant ERD as EntityRender<br/>Dispatcher
participant ZR as ZombieRenderer
participant SNS as SubmitNodeStorage
participant ZM as ZombieModel
LR->>ERD: submit, for each state LevelRenderer.submitEntities walks
ERD->>ZR: submit, the pose translated to the zombie
ZR->>SNS: submitModel — the body, its pose copied
ZR->>ZM: setupAnim — the first pose, for the layers
ZR->>ZR: each RenderLayer.submit, at the layer's own order
ZR->>SNS: submitLeash if leashed, submitNameTag if named, from EntityRenderer.submit
ERD->>SNS: submitFlame if burning, submitShadow if it has pieces
Submit for one zombie: the body, then the one pose the layers need, then the
extras, all of it descriptions filed into SubmitNodeStorage. Nothing here
writes a vertex.
The description API is SubmitNodeCollector and its ordered form. The
methods a renderer is likely to call are
OrderedSubmitNodeCollector.submitModel, .submitItem, .submitText,
.submitNameTag, .submitShadow, .submitFlame, .submitLeash,
.submitBlockModel and .submitCustomGeometry, plus
SubmitNodeCollector.order to choose a bucket — between them eight of the
twelve kinds of node the storage can hold, since text and name tags make the
same kind, the other four being moving blocks (a falling block’s among them), shape
outlines, gizmos and particle groups, which the zombie never submits. SubmitNodeStorage keeps one
SubmitNodeCollection per order, and each collection files what it is given
into one of fifteen named phases — SubmitNodeCollection.solid,
.translucentModels, .breakingOverlay, .outline and eleven more, all
catalogued in submit phases and feature
renderers.
SubmitNodeCollector.order is a global key, not a per-entity one. Order
one means after every entity’s order-zero body in the whole world, not
“after this entity’s body”. That is how the eyes layer, the armour layers and
the enchantment glint stack correctly across a crowd instead of interleaving
one mob’s helmet with another’s head. Armour claims consecutive orders as it
goes, so a dyed, enchanted, trimmed helmet occupies four — leather is the
only dyeable helmet and its equipment definition has two layers of its own —
and exactly one
layer in the game asks for a negative order, to get underneath
everything: SulfurCubeInnerLayer, which has to sit inside the shell it is
drawn with.
The pose stack is transient, and half of it is dropped. A submit copies the current pose; nothing downstream ever sees the stack. Models, items, block models, flames, shape outlines and custom geometry copy the full pose, while shadows, name tags, text, leashes and moving blocks copy only the 4×4, so no normal matrix crosses for those. A leaked push is fatal, but the check happens at the end of the submit phase, on a local stack, not at the end of the frame. Avatars differ in one small way: the crouch offset is removed before the shadow is submitted for a player and after it for everything else, which stops a sneaking player’s shadow sinking into the ground.
The renderers and models being described
Renderers and models are shared and mutable; render states are not. One
ZombieRenderer serves every zombie in the world — though it holds an adult
model, a baby model and two baked armour sets — and safety comes entirely from
the fresh per-entity state and from replaying the animation at prepare time. The
chain is EntityRenderer → LivingEntityRenderer → MobRenderer →
AgeableMobRenderer → HumanoidMobRenderer → AbstractZombieRenderer →
ZombieRenderer. AgeableMobRenderer is deprecated and is not the base of
every humanoid: the enderman and the giant extend MobRenderer directly, the
armour stand and the avatar extend LivingEntityRenderer, all four while
using humanoid models. Players are served by AvatarRenderer, keyed by skin
model rather than entity type — the type map has no entry for the player or
the mannequin, and the dispatcher keeps two avatar maps, one for players and
one for mannequins, each keyed wide and slim and falling back to wide.
Geometry is Model → EntityModel → HumanoidModel, built out of
ModelParts. A model is baked once from a LayerDefinition /
MeshDefinition / PartDefinition tree named by a ModelLayerLocation in
ModelLayers and held in an EntityModelSet; LayerDefinitions is the single
static table that builds every one of them, out of the CubeListBuilder /
CubeDefinition / CubeDeformation / PartPose vocabulary. Posing is
Model.setupAnim, hand-written or driven by an AnimationDefinition of
Keyframes through KeyframeAnimation, whose channels interpolate linearly
or along a Catmull–Rom spline.
Extras hang off RenderLayer — HumanoidArmorLayer, ItemInHandLayer,
CustomHeadLayer, WingsLayer, EyesLayer, CapeLayer and forty-odd more.
Armour and trims funnel through EquipmentLayerRenderer, dressed by
EquipmentAssetManager and EquipmentClientInfo out of the resource packs,
and a renderer holds a whole ArmorModelSet per body size rather than one
armour model. Held or worn items come through ItemModelResolver and
ItemStackRenderState, which models and
atlases covers.
A player is a skin record and seven booleans
Player anatomy leaves the drawing
here, and the answer is that all of it becomes render state at extract like
everything else. AvatarRenderState carries the whole PlayerSkin record by
value — three textures, the arm width and the secure flag — read, for a
player, off the tab-list entry rather than off the entity, which is why a skin
change needs no entity packet; a mannequin carries its own. Beside it sit seven booleans, one per PlayerModelPart:
the hat, the jacket, the two sleeves, the two trouser legs and the cape, each
copied from Avatar.isModelPartShown once per frame, so a customisation
toggle is a part the model is told not to draw rather than a different model.
PlayerModelType is the arm width inside that record, and the dispatcher reads
it back out of the state at submit as the key into the players’ avatar map,
whichever avatar the state came from. The textures themselves arrive by
a road of their own, SkinManager and SkinTextureDownloader, with a
PlayerSkinRenderCache in front for the profiles a mannequin or a head names
and DefaultPlayerSkin standing in until one does.
Prepare: sorting, batching, and the vertices
In: a whole frame of submit nodes.
Out: a FeatureRenderDispatcher.PreparedFrame whose vertices already exist.
Decided: how few draws this can be.
FeatureRenderDispatcher.prepareFrame drains every phase of every order
bucket, groups the nodes, and lets the twelve feature renderers build
geometry — ModelFeatureRenderer being where a ModelPart tree finally
becomes vertices, as it does here for one zombie:
sequenceDiagram
participant LR as LevelRenderer
participant FRD as FeatureRender<br/>Dispatcher
participant MFR as ModelFeature<br/>Renderer
participant ZM as ZombieModel
LR->>FRD: prepareFrame
FRD->>FRD: drain every phase of every order bucket, and group
FRD->>MFR: prepareGroup, for the model submits
MFR->>ZM: setupAnim — the second pose, from the baked one
MFR->>ZM: renderToBuffer — the vertices, written now
Note over LR,ZM: drawn later, when the frame graph runs the main pass
Prepare for one zombie: the model is posed a second time because its submit carried the state but not a pose for every part. The vertices exist before any of the frame graph’s passes has run.
The phases come in two kinds and the kind decides how hard the grouping is
allowed to try. With improved transparency off, as a new client
starts, twelve are a SimpleFeatureRenderPhase, which groups by feature type
and then by batch key — and only two of the twelve kinds of submit have a
batch key at all, a model and a piece of custom geometry. Everything else
keeps the order it was submitted in. Either way, a simple phase’s
RenderTypeFeatureRenderer.Group is free to fold a node’s geometry into
any earlier draw of the same RenderType rather than only the adjacent
one, which is how the zombies of the world collapse into one draw.
The other three phases are a TranslucentFeatureRenderPhase (with improved
transparency on, ten of the fifteen names share one simple phase, and only the
see-through phase sorts). That one keeps
every node, sorts them back to front by squared distance to the camera, and
marks the group strictly ordered — which switches off exactly one of the
group’s two merges. Consecutive submits of one render type still share a
draw. What stops is the fold into a non-adjacent earlier draw, which is the
merge that would move geometry ahead of everything between it and its target,
and so undo the sort the phase exists for. A render type may also opt out of
both merges on its own account, through
RenderType.canConsolidateConsecutiveGeometry, which is how something whose
primitives are chained keeps them chained. Which three phases are the
translucent ones, and what lands in each of the fifteen, is submit phases and
feature renderers.
Why the zombie is animated more than once
Once during submit — but only because it has layers, and
ItemInHandLayer, CustomHeadLayer and several others need posed
ModelParts to hang things off. Once again, per model submission, at
prepare time, because the submit node carried the model and the state but
not a pose for every part. A glowing zombie is animated three times, since the
outline is a second submission of the same model into
SubmitNodeCollection.outline. A fourth pass exists in the machinery — the
crumbling overlay — but no entity ever reaches it: the only non-null
ModelFeatureRenderer.CrumblingOverlay in the game is built in
LevelExtractor’s block-entity loop, so it is a chest being mined that
gets a fourth pose, never a mob. That the repeats are sound at all is only
because Model.setupAnim resets every part to its baked
pose first, so each call is idempotent from a known base — not because the
model is stateless, which it emphatically is not.
Execute: the frame graph pulls the trigger
In: the prepared frame. Out: draws. Decided: almost nothing — the ordering was fixed at submit and prepare.
FeatureRenderDispatcher.PreparedFrame exposes eight drains, each handed the
render pass to draw into, and the frame graph’s main pass is where each is called:
.executeSolid always, .executeOutline when something glows,
.executeTranslucent and .executeTranslucentAfterTerrain for classic
transparency, .executeWaterMask and .executeOit for improved transparency,
and .executeSeeThrough and .executeAlwaysOnTop when something needs them.
Only the solid and classic translucent drains share the main render pass; the
others each get a render pass the main pass opens, the always-on-top one
clearing depth first. Where those passes sit
relative to terrain, sky and post-processing is the subject of visibility and
the frame graph; the draws go out through
blaze3d.
What borrows this pipeline without being it
Block entities take the same four stages under a different visibility policy, a different partial tick and an empty block model — block-entity rendering is the whole of the difference.
The first-person hand is a second pipeline from submit on: its state is
extracted with the player’s (FirstPersonHandsAndItems), then
FirstPersonHandsAndItemsRenderer submits into a SubmitNodeStorage it shares
with the screen effects,
which is drawn by FeatureRenderDispatcher.renderAllFeatures outside the frame graph — see
the frame.
Hitboxes left the renderer. F3+B is a debug-entry toggle read by a separate debug renderer that emits gizmo primitives, suppressed under reduced debug info, and the old hitbox render state record is dead code with exactly two references, both inside its own file. Name tags borrow in the same way: the submit node carries the text already laid out and measured, and every glyph in it is resolved by text and fonts, not here.
For a 1.21-era reader. MultiBufferSource does not exist anywhere in the game, nor any other buffer source: a renderer submits, and the feature renderers write the vertices. RenderTypes.entityCutoutNoCull is now
RenderTypes.entityCutout, the polarity flipped so that the culled variant,RenderTypes.entityCutoutCull, is the one that says so. And ItemBlockRenderTypes is gone: a quad’s layer is read out of its sprite.
Where to look
EntityRenderDispatcher.extractEntity and EntityRenderer.extractRenderState
for the first stage, LivingEntityRenderer.submit for the second — the
clearest single method in the part. Then SubmitNodeCollection for the phase
list and ModelFeatureRenderer for where vertices are written. ModelLayers
and LayerDefinitions for how a model is described, and submit phases and
feature renderers for both catalogues.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Block-entity rendering
Verified against Minecraft 26.3 · Part XI · a chest on the ground and a chest in your hand, drawn in the same frame by two renderers that share one model definition.
You place a chest, step back, and hold a second one up in front of your face.
Both are chests, both are lit, both open the same lid on the same hinge — and
almost nothing about how they got onto the screen is shared. The one on the
ground is a block entity: the terrain mesh at its position contains no
geometry at all, and everything you can see of it was extracted from the live
world by ChestRenderer a few microseconds ago. The one in your hand is an
item: it has no block entity, it is extracted as part of your player, and it
is drawn by a class in a different package that exists for chests that are
not block entities. The seam shows if you type /tick freeze. The chest
on the ground is nailed to its last tick; the chest in your hand keeps
swaying with your view bob, because the two are drawn at different partial
ticks and only one of them respects the freeze.
This page is the sibling of entity rendering, and does
not re-teach it. Four stages run here exactly as they run there — extract
reads the live world into a value object, submit describes what ought to
be drawn without drawing it, prepare groups every description in the frame
and sorts the translucent phases back to front, and execute is the frame graph’s passes issuing the draws —
along with render states that hold no live entity, SubmitNodeCollector, and
the phases a submission can land in. All of that is that page’s, and
all of it is true here. What follows is only the differences, and they are
larger than the shared machinery suggests.
The cast
| class | what it decides | thread |
|---|---|---|
LevelExtractor | which block entities are candidates at all — the two lists it walks | Render thread |
BlockEntityRenderDispatcher | the renderer for a type, and two of the three gates an extraction passes | Render thread |
BlockEntityRenderer | the geometry, and its own answers to how far and off screen | Render thread |
BlockEntityRenderState | one block entity’s whole frame: position, block state, type, light, break progress | a value object |
ClientLevel | membership of the globally-rendered set, decided once when the block entity is added | Render thread |
SpecialModelRenderer | the thirteen shapes an item model cannot express, drawn without a block entity | Render thread |
SpecialModelWrapper | how an item model reaches one — the item road into renderer/special | Render thread |
BuiltInBlockModels | how a block state reaches one, in a model table terrain never reads | a worker thread, on every resource reload |
Three roads to the same collector
flowchart TD
BE["A chest placed in the world"]
IT["A chest as an item: held, shelved or dropped"]
BD["A chest block shown by an entity"]
SEC["LevelExtractor walks the visible sections, then the globally-rendered set"]
BERD["BlockEntityRenderDispatcher — one shared ChestRenderer, one fresh ChestRenderState"]
IMR["ItemModelResolver finds the stack's item model"]
SMW["SpecialModelWrapper opens a layer with its renderer"]
BMR["BlockModelResolver reads the built-in block-model table"]
SBM["SpecialBlockModelWrapper sets its renderer on a BlockModelRenderState"]
CSR["a ChestSpecialRenderer, from renderer/special"]
COL["SubmitNodeCollector — the same phases, the same feature renderers, the same vertices"]
BE --> SEC
SEC --> BERD
BERD --> COL
IT --> IMR
IMR --> SMW
SMW --> CSR
BD --> BMR
BMR --> SBM
SBM --> CSR
CSR --> COL
Three ways a chest reaches the collector: its own block entity on the left, and two roads that end in the same special renderer class on the right. Only the left road’s chest is a block entity; the other two are drawn by whatever holds them.
Two of the three roads end in renderer/special, and that is the package’s
whole reason to exist: a chest that is not a block entity still has to look
like a chest. Only the left-hand road has a visibility policy of its own, or an
extract stage that reads the live world.
| entity | block entity | special model | |
|---|---|---|---|
| what is walked | the level’s renderable entities | the visible sections’ meshes, then a global set | nothing — it is reached from a model |
| the visibility test | a frustum, plus a size-scaled distance | the section is visible, then a per-renderer radius | whatever drew the thing holding it |
| the stages | extract, finalize, submit | extract, submit | resolved in its holder’s extract, submitted in its holder’s submit |
| the state | an EntityRenderState subclass | a BlockEntityRenderState subclass | a layer of an ItemStackRenderState, or a BlockModelRenderState |
| the partial tick | one computed per entity | one for every block entity in the world | its holder’s — in your hand, the player’s, then the camera entity’s |
| where the pose comes from | the dispatcher, from the state’s position | LevelRenderer, translated to the block | the item transform for the display context, or the block model’s own |
| how many | one renderer per entity type, or per skin model for a player or a mannequin | 26 of the 49 types, served by 24 classes — ChestRenderer takes three of them | 13 renderers under 13 ids |
The chest, both halves, one frame
Followed through one frame, band by band, the two chests take these routes:
sequenceDiagram
participant LX as LevelExtractor
participant FPHAI as FirstPersonHands<br/>AndItems
participant BERD as BlockEntity<br/>RenderDispatcher
participant ChestR as ChestRenderer
participant LR as LevelRenderer
participant FPHAIR as FirstPersonHands<br/>AndItemsRenderer
rect rgba(0, 0, 0, 0.04)
Note over LX,FPHAIR: extract — the hand at the player's partial tick, the block entities at the world's
LX->>FPHAI: extractRenderState, at the player's own partial tick
FPHAI->>FPHAI: the held chest resolved — a special renderer, no quads
LX->>BERD: tryExtractRenderState, for a block entity in a visible section
BERD->>ChestR: createRenderState, then extractRenderState, past the off-screen and distance gates
end
rect rgba(0, 0, 0, 0.04)
Note over LX,FPHAIR: submit — into the level's storage, then the hand's own
LR->>BERD: submit, the pose translated to the block
BERD->>ChestR: submit — one model, one sprite, no world access
FPHAIR->>FPHAIR: the held stack's layer submits to the ChestSpecialRenderer
end
Both chests in one frame, extract then submit: the ground chest goes through the block-entity classes, the held one through the hand’s. They have separate renderers, states and storages, and share the chest model’s layer definition.
The two chests are not two runs of one pipeline. The world’s block entities
go through LevelRenderer.submitFeatures into the frame graph. The held
chest is extracted with the player, by FirstPersonHandsAndItems, and
FirstPersonHandsAndItemsRenderer submits it into the hand’s own submit
storage,
drained after the whole world is already on the screen. They never share a
submit node.
The chest’s block model is empty, and there are two tables of them
Open blockstates/chest.json and it names one model for every state. Open
that model and it declares a particle texture and no elements. So when
SectionCompiler walks the section and tesselates every block whose render
shape is MODEL, the chest contributes exactly zero quads to the terrain
mesh — and, in the same pass, adds itself to the section’s list of block
entities. Everything you see of a placed chest is drawn one stage later, by a
renderer, from a snapshot.
That is what a block entity renderer is for: the shapes that a cuboid model
cannot express, and the state that a block state cannot hold. Only 26 of the
49 entries in BlockEntityTypes have a renderer registered in
BlockEntityRenderers, and the other 23 — furnaces, hoppers, barrels,
beehives — are drawn entirely by their block models, like any other block.
Having a block entity does not make a block interesting to look at.
There are two baked block-model tables, and confusing them is easy.
ModelManager.getBlockStateModelSet is the one built from the resource
packs, and it is the one SectionCompiler reads: a chest is empty in it.
ModelManager.getBlockModelSet is built on top of that one and merged with
BuiltInBlockModels.createBlockModels, which attaches a
SpecialBlockModelWrapper to every state of every chest, banner, skull,
shulker box, conduit, decorated pot, bell, enchanting table, end gateway, end
portal and copper golem statue in the game. Nothing in terrain ever reads
that table — that is the whole of the separation, and it is membership rather
than behaviour. BlockModelResolver is its one reader, and every caller that
reads it is an entity renderer: item frames, block displays, minecart
contents, the golems, the block an enderman is carrying, and a few more. It reaches the
block-entity side too, as a field of BlockEntityRendererProvider.Context —
the record the block-entity renderers are constructed from — where nothing
reads it, because a block entity already has a renderer and does not need to
find one through a model. When an entity renderer draws a chest it gets
whatever quads the block has, which for a chest is none, and the special
renderer, because that road draws whatever it finds; terrain simply never asks this table, and reads BlockStateModelSet
instead, where the chest’s entry is empty.
What one entry in the built-in table actually holds
The reason one entry can hold quads and a renderer is the object the road
resolves into. BlockModelResolver hands the model a
BlockModelRenderState, which has a slot for each: a list of
BlockStateModelPart, a transformation, a RenderType, a
SpecialModelRenderer with a transformation of its own, and the tint layers
and light. Every BlockModel implementation writes into that one object, and
what comes out is submitted in one go.
Not every entry in it is a wrapped block, either. The three airs are an
EmptyBlockModel; wildflowers and pink petals are a SelectBlockModel that
branches on the display context; and the two ordinary chests are a
CompositeBlockModel whose special half is a ConditionalBlockModel — the
Christmas switch this page comes back to. Most of the rest are a
CompositeBlockModel too: the block’s own quads and the
special renderer stacked. Five are not, and get the renderer alone with
no quads under it — the bell, the conduit, the end gateway, the end portal
and the enchanting table, whose built-in model is BookSpecialRenderer and
nothing else. Put an enchanting table in a block display and you get a book
hanging in the air.
Frustum-culled by section, not one by one
A block entity is never frustum-tested on its own.
LevelExtractor.extractVisibleBlockEntities starts from
LevelRenderer.visibleSections — the sections of the reachability walk that
survived the frustum, which visibility and the frame
graph describes — and takes each section’s compiled list of block entities whole.
Culling has already happened, one section at a time.
Three gates of visibility decide whether a block entity is extracted from a section, and only
the first belongs to the walk. The other two are stricter than they look, and
BlockEntityRenderDispatcher.tryExtractRenderState holds both. The first is
the section’s own fade-in: a freshly uploaded section reports a visibility
ramping from zero to one over a duration the chunk section fade-in time
option sets, and LevelExtractor skips its block entities until that number
reaches 0.3. Terrain fades in from the first frame, and the chests inside
it appear about a third of the way through — furniture arriving after the
room. The ramp starts at a section’s first upload and a recompile never
restarts it, so the gate bites on arriving terrain at any distance, and never
on a chest you place in terrain already drawn.
The second is the off-screen flag, which the section after this one is about.
The third is a distance test with the same name as the entity one and only
half of its behaviour. EntityRenderer.shouldRender does a size-scaled
distance test and a frustum intersection. BlockEntityRenderer.shouldRender
keeps the distance half alone: a camera position, and whether the block’s
centre is within BlockEntityRenderer.getViewDistance.
Sixty-four blocks — the default, taken by nineteen of the twenty-four
renderer classes, and it does not scale with your render distance the way
Entity.shouldRender does.
| renderer | how far | why |
|---|---|---|
| the other nineteen | 64 | the interface default |
PistonHeadRenderer | 68 | a moving block starts outside the block it is drawn from |
BlockEntityWithBoundingBoxRenderer | 96 | the structure block’s outline is a build tool — and its extraction sets a visibility flag from Player.canUseGameMasterBlocks or spectator mode, so its output, and the box the test-instance renderer draws through it, depends on your permissions |
TheEndGatewayRenderer | 256 | the beam is the thing you are looking for |
BeaconRenderer | the render distance in blocks | and measured horizontally only |
TestInstanceRenderer | the larger of its two delegates | it wraps a beacon and a bounding box |
The beacon is the interesting row twice over. Its BlockEntityRenderer.shouldRender flattens both
positions onto the horizontal plane before comparing, so altitude never costs
you the beam — you can be at build height above a beacon at bedrock and still
see it. And its extraction scales the beam’s radius by the horizontal distance
divided by 96, floored at one, so the beam gets visibly wider the further
away you stand, which is why a distant beacon does not thin into nothing.
Raising a spyglass resets the scale to one, because BeaconRenderer checks
whether the local player is scoping. The topmost beam segment is drawn
BeaconRenderer.MAX_RENDER_Y tall, 2048 blocks up from where it starts.
Off screen means off this list
BlockEntityRenderer.shouldRenderOffScreen picks which of two lists a block
entity is drawn from, and it is enforced twice.
ClientLevel.onBlockEntityAdded puts a block entity into
ClientLevel.getGloballyRenderedBlockEntities only if its renderer says yes,
and BlockEntityRenderDispatcher.tryExtractRenderState throws the extraction
away unless the flag it was called with equals the renderer’s answer. The
second check is what stops double-drawing: a beacon inside a visible section
is in that section’s list and in the global set, and the equality test is
the only thing that picks one. Exactly three renderers say yes —
BeaconRenderer, BlockEntityWithBoundingBoxRenderer and
TestInstanceRenderer, which says yes because either of its two delegates
does.
What a block entity’s snapshot carries
blockentity/state holds 26 classes: the base and twenty-five subclasses. The
base is five fields wide — the position, the block state, the type, packed
light sampled from the level, and the crumbling overlay — and it is filled by
one static method, BlockEntityRenderState.extractBase, that every renderer
calls before adding its own. There is no EntityRenderer.finalizeRenderState
counterpart here: BlockEntityRenderer declares one extraction method, not
two, so there is no second extraction step; the two live handles this page
comes to are held by reference instead.
The crumbling overlay is the fifth field and the only one built outside the
renderer. LevelExtractor looks the block position up in
ClientLevel.destructionProgress, takes the last entry of the sorted set —
BlockDestructionProgress orders on the stage first and the digger’s entity
id only to break a tie, so two players on one block show whichever crack is
further along — and wraps that stage and a pose into a
ModelFeatureRenderer.CrumblingOverlay. This is the only place
in the game a non-null one is made, which is why a mob is never
crumbled and
a chest being mined is. The global list is extracted with a null overlay
outright, so a beacon someone is breaking gets its cracks from its block
model alone, never from its renderer.
Twenty-five subclasses, twenty-six classes: BedRenderState is reachable from
nothing in the game. There is no bed block entity in BlockEntityTypes and no
bed renderer, and a corpus-wide search for the name finds only its own file.
It is the only orphan in the package.
The five states that carry another pipeline’s snapshot
Five states carry another pipeline’s snapshot inside them, which is where the machines touch — and two of the five carry an entity state, not
an item one. SpawnerRenderState.displayEntity is a whole
EntityRenderState, extracted through EntityRenderDispatcher from a display
entity the spawner creates client-side — a mob that LevelExtractor never
sees, never frustum-tests, and whose light the spawner overwrites with the
block’s. VaultRenderState.displayItem reads like the item cases and is not
one: it is an ItemClusterRenderState, which extends EntityRenderState, and
the vault submits it through ItemEntityRenderer — the same renderer that
draws a dropped item lying on the ground. The three genuine item carriers are
ShelfRenderState.items, an array of three ItemStackRenderState — so a
shelved item is resolved and submitted by the item road, from inside a
block-entity render state, and lands on whichever special renderer its own
model names — and CampfireRenderState.items and
BrushableBlockRenderState.itemState for what is cooking and what is buried.
Two live handles survive into snapshots, and the first is not unique to this
side:
MovingBlockRenderState is a one-block fake world that holds the level’s
MovingBlockRenderState.lightEngine and MovingBlockRenderState.cardinalLighting
by reference, so a moving block is lit at prepare time rather than at
extract. PistonHeadRenderState carries up to two of them, and the entity
side’s FallingBlockRenderState carries one.
Signs are the second. SignRenderState stores the block entity’s own two
SignText objects rather than laid-out glyphs, and AbstractSignRenderer
calls Font.split during submit when that text has no lines cached for
the current filtering setting — a stage later than the rest of the snapshot.
One partial tick for the whole world
This is the difference a player can see. Six partial ticks are in play in
one frame, and
block entities take the row that has no exception in it. The entity side is
asked per entity, TickRateManager.isEntityFrozen at a time, so a mob exempt
from a freeze keeps interpolating while its neighbours stop. Block entities
get no such question: they all receive the single
DeltaTracker.getGameTimeDeltaPartialTick value the world got, which returns
exactly 1.0 while the game is frozen. Every block entity in the world is
therefore pinned to its last completed tick, with no per-block exemption
anywhere in the path.
The held chest is asked the entity side’s question, twice:
FirstPersonHandsAndItems.extractRenderState runs at the local player’s own
partial tick, and the hand is posed at submit off
Camera.getCameraEntityPartialTicks, which puts the same question to the
camera entity. The frozen check never freezes a Player, so under
/tick freeze the item in your hand is redrawn from a live partial tick while
every chest lid in the world is stopped dead. A mob exempt from the freeze, a chest lid and the chest in your hand: three
things drawn in one frame, and whether each one moves depends only on which
question it was allowed to ask.
The same split shows up in the Christmas textures, which the game implements
three times. ChestRenderer reads SpecialDates.isExtendedChristmas once,
in its constructor, and its constructor runs only when
BlockEntityRenderDispatcher.onResourceManagerReload rebuilds every renderer
— so a placed chest that was ordinary at 23:59 on the 23rd stays ordinary
until the next resource reload. The item model in items/chest.json selects on
the minecraft:local_time property, whose LocalTime implementation re-checks
the clock at most once a second. And the built-in block model wraps its two
chests in a ConditionalBlockModel whose IsXmas property calls the same
static method live, every time the model is resolved. Two of the three notice
midnight almost at once. The one you are standing in front of does not.
Where renderer/special borrows its geometry
SpecialModelRenderers.bootstrap registers thirteen renderers under thirteen
ids, dispatched by a codec, and the package holds exactly thirteen renderer
classes to match. Nine of them implement NoDataSpecialModelRenderer and read
nothing at all from the stack; the other four — banner, decorated pot, player
head and shield — pull what they need out of it through
SpecialModelRenderer.extractArgument, which is the closest thing this road
has to an extract stage.
Eleven of the thirteen reach into client/renderer/blockentity for their
geometry. Three hold an instance of the block-entity renderer outright
(BannerSpecialRenderer, DecoratedPotSpecialRenderer,
ShulkerBoxSpecialRenderer); the rest call a static submit or name a static
texture or model layer on one — SkullBlockRenderer.submitSkull,
AbstractEndPortalRenderer.submitSpecial, BannerRenderer.submitPatterns,
ChestRenderer.LAYERS. Only TridentSpecialRenderer and
CopperGolemStatueSpecialRenderer stand alone. The chest in your hand really
is a ChestModel, baked from the same ModelLayerLocation, posed at a
fixed openness instead of an interpolated one.
ChestRenderer aside, this page teaches the shape of the twenty-four rather
than the instances, because a family is what they are: each is one
BlockEntityRenderer.extractRenderState and one BlockEntityRenderer.submit,
and the interesting ones differ in how far they can be seen and from which
list, both above, and in what they put in their render state — the structure
block drawing gizmos instead of submitting geometry, and the test instance
drawing gizmos beside a submitted beacon beam. The shared piece
worth naming is
WallAndGroundTransformations, which is how a skull, a banner or a sign
answers am I on the floor or on a wall — one transformation per
Direction for the wall case and an array indexed by rotation segment for
the free-standing one, built once into a static field of the renderer rather
than per frame.
How an empty item model turns into a chest
Reaching a special renderer from an item is one indirection: SpecialModelWrapper is an
ItemModel like any other, and its baked form puts the renderer into a layer
of the ItemStackRenderState through
ItemStackRenderState.LayerRenderState.setupSpecialModel. A layer either has
quads or has a special renderer, never both, and the layer’s submit picks
whichever it has. That is the entire mechanism by which an empty item model
turns into a chest.
For a 1.21-era reader. BedRenderer is gone, and so is the bed’s block entity: a bed is drawn by its block models like any other block, and the
BedRenderStatethe renderer filled is left inblockentity/statewith nothing to fill it.
Where to look
BlockEntityRenderDispatcher.tryExtractRenderState first — it contains two of
the three visibility gates. Then
LevelExtractor.extractVisibleBlockEntities for the two lists it walks, and ChestRenderer as the clearest renderer in the package, since its
counterpart in renderer/special is the one this page compares it with. For the
other road, SpecialModelWrapper and ItemStackRenderState.LayerRenderState,
then BuiltInBlockModels for the block-state road nobody expects to exist.
Submit phases and feature renderers is the
catalogue everything here submits into.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Lightmap, fog and sky
Verified against Minecraft 26.3 · Part XI · the sun goes down: the light, the fog and the sky, traced back to the day’s keyframe curves.
Stand on a hill and watch the light go. The sky over the taiga slides from
blue towards black, the murk closes in until the far trees dissolve, stars
come up, the moon takes whatever shape it is owed tonight, and if a storm
arrives the scene goes grey and streaked. Five renderers make those colours —
Lightmap decides how bright, FogRenderer how far, SkyRenderer and
CloudRenderer what is up there, WeatherEffectRenderer what is coming down
— and four of them ask one question above all: what is this attribute worth,
here, now? The surprise is who they ask. Most of them no
longer know what time it is. They ask a probe for a named value at a
position and a partial tick, and the day/night curve behind it is keyframes in
a data pack. Two still read the level’s raw game time — the clouds, because they
drift, and the rain, whose texture scrolls — but the weather asks nobody for a
colour: it seeds each column of rain from that column’s own coordinates, and
touches no attribute and no probe at all.
The cast
| class | what it decides | thread |
|---|---|---|
EnvironmentAttributeProbe | what any attribute is worth at the camera — cached once a tick, read many times a frame | Render thread |
LightmapRenderStateExtractor | the lightmap’s ten uniforms, and whether to redraw at all | Render thread |
Lightmap | how bright, as the 16×16 texture every terrain vertex samples | Render thread |
FogRenderer | how far you can see, in what colour, and in which medium | Render thread |
SkyRenderer | what hangs above the horizon — and which of two skies it is | Render thread |
CloudRenderer | the cloud cells, and the face list built from them | the reload’s CloudRenderer.prepare bakes on a worker, the rest on Render |
WeatherEffectRenderer | which columns get rain, which get snow, and how hard | Render thread |
LevelRenderer | which of those draw at all, and inside which frame-graph pass | Render thread |
What a renderer has to know about an attribute, and no more
An environment attribute is a named, typed quantity — a colour, a
distance, an angle, a moon phase — that the world answers for a position and
an instant. EnvironmentAttributeSystem assembles that answer by running a
short stack of layers over the attribute’s own default: the dimension, the
biome, one layer per timeline the dimension runs, and weather where a
dimension can have it. That machinery belongs to environment attributes and
timelines; this page
assumes it and names only what it consumes. Three consequences shape
everything below.
The client resolves the same stack from the same data — it is never sent
a resolved colour — and what it adds is EnvironmentAttributeProbe, on the
camera: a per-tick cache that smooths in space and in time and evicts
anything nobody asked
for.
What matters on this page is that
every renderer here that asks for an attribute at all goes through the probe
and never through the system — and one of the five asks for none.
Whether a value can smooth or must step is declared on the attribute’s
type, and whether it smooths across a biome border on the attribute itself,
not chosen by the renderer — which is why the sky colour slides and
EnvironmentAttributes.MOON_PHASE snaps — and the shape of a smooth one’s
curve in time is the timeline’s data, its track’s ease.
ClientLevel adds two layers of its own on top of the four, and both are
the lightning flash (the stack a value falls
through).
Neither has anything to do with the End’s sky flash, which never
enters the stack at all.
What the dimension type and the biome still carry
Some of the old per-dimension and per-biome data survived the migration
unchanged. DimensionType.skybox is a
three-valued DimensionType.Skybox — DimensionType.Skybox.NONE,
DimensionType.Skybox.OVERWORLD, DimensionType.Skybox.END — and it is a
branch, not a colour. BiomeSpecialEffects holds only
BiomeSpecialEffects.waterColor, BiomeSpecialEffects.grassColorOverride,
BiomeSpecialEffects.grassColorModifier and the foliage colours: every fog
and sky colour comes from Biome.getAttributes. None of it crosses the
network as pixels — the inputs arrive as registry sync during configuration,
attribute maps filtered through EnvironmentAttributeMap.NETWORK_CODEC, then
world time and weather during play (protocol
phases) — so a data pack retints a
dimension without touching the client.
The five askers
| renderer | what it asks for | when it asks | what it produces |
|---|---|---|---|
Lightmap, through LightmapRenderStateExtractor | how bright block light and sky light should read, and in what tint | once per tick, at a partial tick of exactly one | ten std140 uniforms and one 16×16 texture |
FogRenderer | the colour of the murk and the six distances it lives between | once per frame, inside the camera extract | one FogData, uploaded as one UBO slice |
SkyRenderer | where the sun, moon and stars are, and how bright | once per frame | a SkyRenderState for the sky pass |
CloudRenderer | what colour the clouds are and how high they sit | once per frame, read for it by LevelExtractor | a compressed face list, rebaked only when it must be |
WeatherEffectRenderer | no attribute: the rain level, then each column’s height, precipitation and light | once per frame | two lists of WeatherEffectRenderer.ColumnInstance, rain and snow |
Dusk, from a keyframe track to five renderers
sequenceDiagram
participant LRSE as LightmapRender<br/>StateExtractor
participant EAP as Environment<br/>AttributeProbe
participant EAS as Environment<br/>AttributeSystem
participant LM as Lightmap
participant FR as FogRenderer
participant SR as SkyRenderer
rect rgba(0, 0, 0, 0.04)
Note over LRSE,SR: a client tick
LRSE->>LRSE: tick — the flicker walks, the flag raised
EAP->>EAP: tick — each value rolled over or evicted, the biome blend resampled
EAS->>EAS: invalidateTickCache — cached values stale, none recomputed
end
rect rgba(0, 0, 0, 0.04)
Note over LRSE,SR: each frame's extract, in this order
opt the flag a tick raised, so at most once a tick
LRSE->>EAP: getValue(SKY_LIGHT_FACTOR, BLOCK_LIGHT_TINT, …) at partial tick 1
end
EAP->>EAS: getValue, an attribute's first ask this tick — timelines sampled now
LRSE->>LM: LightmapRenderState, and the flag
FR->>EAP: getValue(FOG_COLOR, SUNRISE_SUNSET_COLOR, …) at the frame's partial tick
SR->>EAP: getValue(SUN_ANGLE, SKY_COLOR, SUNRISE_SUNSET_COLOR, …)
end
rect rgba(0, 0, 0, 0.04)
Note over LRSE,SR: each frame's render
opt the same flag
LM->>LM: render — one three-vertex draw into the 16×16 texture
end
SR->>SR: render, in the sky pass — disc, fan, sun, moon, stars
end
Dusk across one tick and one frame, drawn for the lightmap, the fog and the sky. The probe asks the attribute system only on an attribute’s first read in a tick, and the lightmap asks and draws only when a tick has raised its flag.
The clouds are left out because they add nothing new to the picture:
LevelExtractor reads their colour and height from the same probe during the
extract. The rain asks it nothing.
The middle band’s order is the order GameRenderer.extract makes its calls in. GameRenderer.extract runs
LightmapRenderStateExtractor.extract, then GameRenderer.extractCamera —
where FogRenderer.setupFog stashes its FogData on
CameraRenderState.fogData — then LevelExtractor.extract, which drives
WeatherEffectRenderer.extractRenderState and
SkyRenderer.extractRenderState. Then GameRenderer.renderLevel uploads the
fog with FogRenderer.updateBuffer, takes one slice with
FogRenderer.getBuffer, and LevelRenderer.render declares the passes.
SkyRenderer.renderSunriseAndSunset is the clearest instance of the pattern.
The sunrise fan’s colour is EnvironmentAttributes.SUNRISE_SUNSET_COLOR, an
ARGB keyframe track, and its visibility is that colour’s own alpha channel —
which the renderer also scales the fan’s depth by. The fade is a property of
the data, not of the geometry, so a data pack restyles the sunset without a
line of client code changing.
How bright: one draw per tick, and no partial ticks at all
Lightmap is a 16×16 GpuTexture plus a MappableRingBuffer of uniforms.
Lightmap.render writes those uniforms and issues one three-vertex draw
with RenderPipelines.LIGHTMAP: the brightness curve lives in the shader and
the whole texture is a by-product of it.
What it draws from is LightmapRenderState: ten values in std140 order — six
floats from LightmapRenderState.skyFactor and
LightmapRenderState.blockFactor to LightmapRenderState.brightness, then
four colours, LightmapRenderState.blockLightTint and
LightmapRenderState.skyLightColor from
EnvironmentAttributes.BLOCK_LIGHT_TINT and
EnvironmentAttributes.SKY_LIGHT_COLOR, the other two from
EnvironmentAttributes.AMBIENT_LIGHT_COLOR and
EnvironmentAttributes.NIGHT_VISION_COLOR — and an eleventh field,
LightmapRenderState.needsUpdate, which is not a uniform at all but the flag
that decides whether the draw happens. LightmapRenderStateExtractor.tick
runs the torch-flicker random walk in
LightmapRenderStateExtractor.blockLightFlicker and raises its own copy of
the flag; LightmapRenderStateExtractor.extract copies it across, clears it,
and reads the probe alongside Options.gamma, Options.darknessEffectScale,
the conduit-power water vision and
LightmapRenderStateExtractor.calculateDarknessScale. So the lightmap is
recomputed once per tick and not once per frame — and, deliberately,
GameRenderer.extract hands the extractor a partial tick of exactly one
while FogRenderer.setupFog and SkyRenderer.extractRenderState get the
real one. Sky and fog interpolate mid-tick. World lighting steps.
Three leftovers. Lightmap.getBrightness survives but no longer feeds the
lightmap: it is a CPU-side version of the shader’s curve that also mixes in the
dimension’s ambient light, kept for Hud,
EntityRenderer’s shadow sampling and LevelExtractor’s underwater overlay
alone. The packing statics moved out of the texture into LightCoordsUtil, from where a
packed value reaches a vertex through VertexConsumer.setLight. And there are two lightmaps, not one: GameRenderer.levelLightmap always
returns the real 16×16 texture, UiLightmap is a 1×1 white
DynamicTexture, and GameRenderer.lightmap hands out the second for
exactly as long as GameRenderer.useUiLightmap is set — which is the GUI
block and nothing else, and which is why the HUD is not shaded by the light
you are standing in.
Two curves that look like one
EnvironmentAttributes.SKY_LIGHT_FACTOR is a visual attribute, spatially
interpolated, and the lightmap reads it;
EnvironmentAttributes.SKY_LIGHT_LEVEL is a gameplay attribute, not
positional, and Level.updateSkyBrightness turns it into Level.skyDarken,
which gameplay reads, from mob spawning to whether it is bright outside, and
which reaches the screen through the three CPU-side brightness readers above. Timelines.OVERWORLD_DAY keyframes both, at slightly
different times and to different night values — so they look like one number,
and a data pack can pull them apart.
How far: one block for the whole frame, filled by a priority walk
FogRenderer‘s output is a mutable FogData: FogData.color plus six
distances — a start and an end each for the medium and the horizon, then
FogData.skyEnd and FogData.cloudEnd. In open air the colour starts
from EnvironmentAttributes.FOG_COLOR and leans toward
EnvironmentAttributes.SUNRISE_SUNSET_COLOR when you face the sun and toward
a weather-darkened EnvironmentAttributes.SKY_COLOR, and the medium’s and the
sky’s and clouds’ distances come from EnvironmentAttributes.FOG_START_DISTANCE,
EnvironmentAttributes.FOG_END_DISTANCE,
EnvironmentAttributes.SKY_FOG_END_DISTANCE and
EnvironmentAttributes.CLOUD_FOG_END_DISTANCE, shifted by rain and clamped by
the render and cloud distances, the render distance alone setting the horizon
pair in every medium; underwater they are
EnvironmentAttributes.WATER_FOG_COLOR,
EnvironmentAttributes.WATER_FOG_START_DISTANCE and
EnvironmentAttributes.WATER_FOG_END_DISTANCE instead. It owns one ring
buffer, FogRenderer.regularBuffer, and beside it a second buffer of its
own, FogRenderer.emptyBuffer, filled with infinitely far for when fog is
off.
There is one fog UBO for the whole frame, not one per pass.
LevelRenderer.render takes a single slice and hands the same one to the
sky and main passes, and to nothing else: the clouds, the weather and the
always-on-top features draw inside the main pass, and the clouds read a cloud
fog end out of the same buffer anyway, because the binding is sticky and each
of them binds whatever was last set. The sky and cloud fog ends are separate fields
inside that one block, which the shaders choose between, so what a player
sees as per-element fog is a shader decision and not a binding.
The list that decides the colour
The colour and the darkening come from different places.
FogRenderer.FOG_ENVIRONMENTS is an ordered list and the order is the
priority: LavaFogEnvironment, PowderedSnowFogEnvironment,
BlindnessFogEnvironment, DarknessFogEnvironment, WaterFogEnvironment,
and AtmosphericFogEnvironment last, which answers only for open air. FogRenderer.computeFogColor makes one pass down
that list carrying two independent latches — it takes the colour from the
first environment whose FogEnvironment.providesColor is true and the
darkness from the first whose FogEnvironment.modifiesDarkness is, which need
not be the same one — whereas
FogEnvironment.setupFog stops at the first applicable one, and it and
FogEnvironment.isApplicable are the class’s only abstract methods.
MobEffectFogEnvironment declares FogEnvironment.providesColor false on
purpose: blindness and darkness may darken somebody else’s colour, never
supply one, and each medium has exactly one environment that does. Which medium the camera is in is a FogType (FogType.WATER,
FogType.LAVA, FogType.POWDER_SNOW, FogType.ATMOSPHERIC,
FogType.NONE), and NONE maps to the atmospheric environment. Rain fog is
the only stateful one: AtmosphericFogEnvironment.rainFogMultiplier is an
exponential follower, so the murk lags a storm starting rather than
snapping to it, and AtmosphericFogEnvironment.updateRainFogState thickens it
even in a biome with no precipitation, at half strength.
What is up there: two skies, and a texture that is never bound
SkyRenderer builds every buffer it will ever need in its constructor, from
SkyRenderer.buildStars to SkyRenderer.buildMoonPhases against the
AtlasIds.CELESTIALS atlas, and per frame fills a SkyRenderState running
from SkyRenderState.skybox through EnvironmentAttributes.SUN_ANGLE,
EnvironmentAttributes.MOON_ANGLE, EnvironmentAttributes.STAR_ANGLE and
EnvironmentAttributes.STAR_BRIGHTNESS to SkyRenderState.endFlashIntensity.
The stars are the same in every world. SkyRenderer.buildStars seeds a
fixed constant and rejects samples outside a shell, so SkyRenderer.STAR_COUNT
is an attempt count rather than a star count, and they are rebuilt only when a
resource reload takes the whole renderer down:
LevelExtractor.onResourceManagerReload sets
LevelExtractor.shouldResetSkyRenderer and LevelRenderer.addSkyPass closes
and reconstructs the entire SkyRenderer, stars, moon phases and all. The moon
phase, likewise, is no longer arithmetic on the day count — it is
EnvironmentAttributes.MOON_PHASE driven by Timelines.MOON, whose period is
MoonPhase.COUNT days, and the renderer picks a sub-quad of an eight-quad
buffer by MoonPhase.index.
The End takes a different branch entirely. With
DimensionType.Skybox.END, SkyRenderer.extractRenderState fills only the
End-flash fields: the sun angle, the moon phase, the sky colour and the dark
disc are never sampled. And EndFlashState is not the dragon fight — it is a flash on a six-hundred-tick cycle of the dimension’s clock, seeded per interval for its
offset, duration and angles, advanced by EndFlashState.tick in any dimension
whose skybox is the End’s. The sky is also skipped five ways, four of them in one method:
LevelRenderer.addSkyPass bails in lava, in powder snow, when
CameraRenderState reports that a mob effect blocks the sky — which is
blindness and darkness folded into one boolean before the method is entered —
and when DimensionType.Skybox is NONE, which is the Nether. The fifth is
outside it: GameRenderer.renderLevel suppresses the sky when a boss bar
wants world fog, with AtmosphericFogEnvironment.setupFog clamping the fog
hard in that case.
The clouds, which are never handed a fog slice or their texture
The clouds are the first of the two exceptions: their colour and height are
EnvironmentAttributes.CLOUD_COLOR and EnvironmentAttributes.CLOUD_HEIGHT,
but their drift is the level’s raw game time. And the cloud texture is never bound as
a texture. The reload’s CloudRenderer.prepare does the whole job on a worker — reading
the image and baking it into CloudRenderer.TextureData through
CloudRenderer.packCellData, one 64-bit word per pixel with the colour in the
high bits and four neighbour-emptiness flags in the low four — and
CloudRenderer.apply is two statements on the Render thread that install the
result and raise the rebuild flag.
CloudRenderer.buildMesh walks cells of CloudRenderer.CELL_SIZE_IN_BLOCKS,
writing three bytes per face through CloudRenderer.encodeFace — a compressed
face list, expanded to quads in the shader, with
CloudRenderer.RelativeCameraPos and CloudStatus deciding which faces
exist. The per-frame CloudRenderer.prepare, at the top of the main pass,
rebuilds it after a reload or a full re-mesh of the world, when the camera’s
cell of the drifting cloud grid or its side of the layer changes, or when the
CloudStatus changes — and a data pack setting the cloud
colour to zero alpha skips the clouds entirely.
What is coming down: rebuilt every frame, and seeded from the ground
WeatherEffectRenderer is the second exception, and the one that asks least
of anybody. Each column’s randomness is seeded from a hash of its own x and
z — so the same column of rain keeps its speed and its offsets every frame it exists, and
two clients standing in the same storm see the same drops in the same places
without a byte crossing between them. Game time enters only afterwards, to
scroll the streaks down the quad, and snow’s sideways too. It holds one
WeatherEffectRenderer.vertexBuffer and the precomputed tangent tables
WeatherEffectRenderer.columnSizeX and WeatherEffectRenderer.columnSizeZ,
and its per-frame product is two lists of
WeatherEffectRenderer.ColumnInstance records, rain and snow, inside a
WeatherRenderState.
WeatherEffectRenderer.extractRenderState returns immediately when the rain
level is zero, so a clear sky costs nothing. Otherwise it loops every column
in a square of radius Options.weatherRadius, querying the heightmap and the
precipitation at each — every frame, on the CPU. The vertex buffer is rebuilt in
WeatherEffectRenderer.prepare, at the top of the main pass, rather than in
extract, and rain and snow are two indexed draws sharing it. WorldBorderRenderer
rides in the same pass and is nothing to do with the weather: the main pass
simply draws the two of them back to back after the translucent terrain — a
single time, or once per stage with improved transparency — and the
border’s WorldBorderRenderer.prepare is handed the render distance and the
far plane, which bound the wall’s width and its height. What the border
is belongs to level data and rules.
Particles and sound are somebody else’s job: ClientLevel.tickWeatherEffects
spawns those per tick within the same radius, next to
ClientLevel.animateTick, whose scatter of
EnvironmentAttributes.AMBIENT_PARTICLES belongs to
particles.
What is not an attribute
The migration was not total, which is why everything is an attribute now
needs a qualifier. DimensionType.ambientLight and
DimensionType.cardinalLightType are plain record fields, read directly —
and the first of the two no longer reaches the lightmap at all: its two readers are
Lightmap.getBrightness, the CPU-side version this page has already said
the shader does not use, and one deprecated method on LevelReader.
Directional shading, which is per dimension and is not data
The second of those is the one you can see. How bright a face is by which way it points
comes from a CardinalLighting record, and there are exactly two in the
game: CardinalLighting.DEFAULT and CardinalLighting.NETHER, both
hard-coded. DimensionType carries the choice between them and nothing else
— a data pack picks, it does not supply numbers. The choice is read in two places:
ClientLevel.cardinalLighting hands the record to the face shading of the mesher and of moving blocks,
and Lighting, a single UBO of two diffuse light directions sliced five ways,
one slice per Lighting.Entry, picks the directions it writes by the same
choice. Four of the five —
Lighting.Entry.ITEMS_FLAT, .ITEMS_3D, .ENTITY_IN_UI and
.PLAYER_SKIN, which is why a flat item in a slot, a side-lit one and
the player in the inventory screen are each lit differently — are written once in
the constructor and never again. Only Lighting.Entry.LEVEL is rewritten,
by Lighting.updateLevel, each time the game renderer is given a level.
Block tint never moved at all: grass, foliage and water
are still BiomeColors reading BiomeSpecialEffects through the four
ColorResolvers, with no probe and no layer stack in it. And the clouds still
read the level’s game time, because a value sampled at the camera and lerped by
partial tick is the wrong shape for a drift — as does the weather, whose
streaks scroll off it, though the weather is the one renderer here that asks
for no attribute at all and seeds each column from its own coordinates.
For a 1.21-era reader. LightTexture is now
Lightmap, with its packing statics inLightCoordsUtil. LevelRenderer.addCloudsPass and LevelRenderer.addWeatherPass are gone: the clouds and the weather are draws insideLevelRenderer.addMainPass, and only the sky keeps a pass of its own,LevelRenderer.addSkyPass(visibility and the frame graph).
Where to look
LightmapRenderStateExtractor.extract first, then Lightmap.render for what
it feeds. EnvironmentAttributeProbe.getValue for the question every renderer
here asks, and environment attributes and
timelines for how it is
answered. FogRenderer.computeFogColor for the priority walk.
SkyRenderer.extractRenderState and LevelRenderer.addSkyPass for the sky
and its two branches, CloudRenderer.buildMesh and
WeatherEffectRenderer.prepare for the meshes rebuilt inside the
frame, and BiomeColors for the colour system that did not move.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
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, 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, which owns
LevelEventHandler; this page owns what the client does with the puff once
the switch has decided.
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),
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.
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
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. 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).
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). 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 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.getLightCoordsand ParticleFeatureRenderer isQuadParticleFeatureRenderer. SingleQuadParticle.Layer.TERRAIN and ITEMS each split into an opaque and a translucent layer, whichSingleQuadParticle.Layer.bySpritepicks between, and a fullParticleGroupnow refuses a newcomer where it used to evict its oldest.ParticleResources,ParticleRenderTypeandParticleLimitkept 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.
Post-processing
Verified against Minecraft 26.3 · Part XI · you press Escape and the world goes soft behind the menu — and the machine that softened it is the one that turns everything green when you spectate a creeper.
The blur behind the pause menu is not a GUI effect. It is a post-processing
chain: a file called blur.json, sitting in the jar beside creeper.json,
in the same format, loaded by the same loader, compiled into the same kind of object and run by the same two classes, PostChain and PostPass. Five such files ship, covering the
pause menu, the three things it is unpleasant to spectate and the glow around
a spectral-arrowed mob. A resource pack can rewrite every one of the five,
with as many passes as it likes, running fragment programs it also ships — a
real and underused amount of rope. It can also add a sixth: the list of
chains run over the world every frame starts with end_of_frame, and no file
in the jar answers to that name.
The cast
| class | what it decides | thread |
|---|---|---|
PostChainConfig | what a chain is as data: its own targets, and its passes in order | parsed on a worker |
ShaderManager | which chains exist, when they are compiled, and when they are thrown away | configs read and pipelines compiled on workers (a chain’s while the Render thread waits), the rest on the Render thread |
PostChain | which targets a pass may name, and where a pass’s output lives | Render thread |
PostPass | one draw: a pipeline, its inputs as samplers, its uniforms as buffers | Render thread |
UniformValue | the seven types a JSON-declared uniform may have, and how each is packed | decoded on a worker, packed on the Render thread |
LevelTargetBundle | the two names the level’s targets answer to, and which set a chain may ask for | Render thread |
LevelRenderer | the one chain that becomes passes in the world’s own frame graph | Render thread |
GameRenderer | the blur and the per-frame list of chains, each given a frame graph of its own, built and thrown away on the spot | Render thread |
Nothing here talks to a driver directly: a pass is a RenderPipeline and a
RenderPass like every other draw in the game, and the
vocabulary this page spends — BindGroupLayout, GpuBuffer,
MappableRingBuffer, Std140Builder, TextureTarget — is taught on
the Blaze3D page.
The graph the passes go into belongs to visibility and the frame
graph.
From a file on disk to a pass in a graph
flowchart TD
subgraph RELOAD["once per resource reload"]
DISK["a JSON file under post_effect, and the GLSL it names"]
PREP["ShaderManager.loadConfigs, on a worker: one PostChainConfig each"]
APPLY["the apply half: every cached chain closed, a new empty cache"]
end
subgraph FRAME["inside a frame"]
GET["ShaderManager.getPostChain, on the Render thread"]
ASK{"cached already?"}
LOAD["PostChain.load: one PostPass per pass, each pipeline compiled now"]
CACHE["cached by id, and only by id"]
ADD["PostChain.addToFrame: the targets resolved"]
PASS["PostPass.addToFrame: one FramePass per pass"]
EXEC["FrameGraphBuilder.execute: three vertices a pass"]
OUT["the last pass writes an imported target"]
end
DISK --> PREP --> APPLY
APPLY -- "a later frame asks" --> GET --> ASK
ASK -- "no, the first ask" --> LOAD --> CACHE --> ADD
ASK -- "yes" --> ADD
ADD --> PASS --> EXEC --> OUT
A chain’s life in its two phases: the reload parses every file and empties the cache, and the first frame to ask for a chain compiles and caches it. Every later ask is answered from the cache.
The chain splits across two phases of the client’s life, and the split is the thing to hold: the top box happens once, during a resource reload, and the bottom one inside a frame, every frame the effect is on — all but the compile and the cache entry, which happen only on the first ask after a reload. That first ask is inside a frame, which is what makes the compile the awkward step.
What a chain declares, and the two kinds of name in it
A PostChainConfig is two things: a map of targets it wants for itself,
and a list of passes in the order they run. That is the entire schema.
Each pass names a vertex program and a fragment program by Identifier, an
output target, a list of inputs and a map of uniform blocks. An input is one
of exactly two shapes: PostChainConfig.TargetInput names another target and
may ask for its depth attachment rather than its colour, and
PostChainConfig.TextureInput names a PNG under textures/effect with its
dimensions. Both carry a sampler name, and two inputs on one pass sharing
one is rejected by the codec while the file is being parsed, so the chain
never reaches the config map at all rather than failing later when something
asks for it. That name is the contract with the GLSL:
PostChain appends Sampler to it when it builds the pass’s
BindGroupLayout, so an input called In is the shader’s InSampler.
The distinction that matters is between the two kinds of target name. A name
in the chain’s own targets map is internal: it belongs to the chain, is
created fresh inside the frame graph at screen size unless the chain
overrides that, and is gone when the graph finishes. Any other name is
external and must be supplied by whoever runs the chain.
PostChainConfig.Pass.referencedTargets collects both kinds,
PostChain.load subtracts the internal ones, and what remains must be a
subset of the allowed set the caller passed in —
LevelTargetBundle.MAIN_TARGETS or LevelTargetBundle.OUTLINE_TARGETS — one
name or two, each set being the main target plus whatever else that caller is
prepared to hand over. A chain naming a target its caller did not offer does
not load at all. An internal target may also be declared persistent, in which case PostChain allocates it itself, again whenever its size changes, keeps it in
PostChain.persistentTargets until the chain drops out of the frame’s list, and
imports it rather than creating it, so a
pass can read what it wrote last frame. None of the five asks for one.
Loaded off-thread, compiled inside a frame
ShaderManager is a PreparableReloadListener that splits its own work
across the two places a reload
listener has.
ShaderManager.loadConfigs runs on the reload’s worker executor: it reads
every GLSL source and include under shaders, and parses every JSON under
post_effect with PostChainConfig.CODEC into an immutable map — a
malformed chain is logged and simply absent from it. Still before the
barrier, ShaderManager precompiles the static pipeline
catalogue through GpuDevice.compilePipeline, whose
compiler resolves each source’s #include
directives against
what was just read. ShaderManager.apply then runs on the Render thread and
installs the compiled set as a new PipelineCache.
Post chains are not in that precompiled set. They are built lazily, the first
time somebody asks: ShaderManager.getPostChain consults the cache, and on a
miss PostChain.load walks the config, builds a RenderPipeline per pass
from RenderPipelines.POST_PROCESSING_SNIPPET, names it chain id slash
pass index, and precompiles it there and then. The first frame you
spectate a creeper compiles two shader programs in the middle of itself. A
failure throws ShaderManager.CompilationException, which
ShaderManager.getPostChain logs, caches as an absence until the next reload so the next frame does not try again, and, for the first such failure since that reload, reports to Minecraft.triggerResourcePackRecovery — the path that deselects every resource pack it can and reloads, or, when none can be deselected, crashes the game if vanilla is all that is selected and otherwise returns to the title screen. Closing the old cache, meanwhile, closes every
PostChain in it, destroying its persistent targets and freeing each
PostPass’s uniform buffers: a reload does not rebuild the chains, it
forgets them.
A pass is three vertices, and its uniforms are written once, at load
PostPass.addToFrame adds one FrameGraphBuilder.addPass named after its
pipeline’s location. Every target input becomes a FramePass.reads, the
output a FramePass.readsAndWrites, and the body goes in through
FramePass.executes — nothing is drawn while the graph is built.
When the body eventually runs, it sets an orthographic projection —
ShaderManager keeps one Projection and one ProjectionMatrixBuffer and
lends them to every chain — writes the output size and each input’s size into
a MappableRingBuffer as the SamplerInfo block, then opens a RenderPass,
binds pipeline, default uniforms, custom uniform blocks and inputs, and draws.
Three vertices make every post-processing draw, in all twenty-four passes
the five chains declare (PostPass.addToFrame). There is no quad and no
vertex buffer: both vertex programs the chains use build one oversized triangle out of the vertex index alone, and the fragment shader sees the whole screen.
The uniforms are stranger than they look. A UniformValue has seven types —
int, ivec3, float, vec2, vec3, vec4 and a 4×4 matrix — and a block is a list
of them under a name. PostPass’s constructor sizes the block with
UniformValue.addSize, packs it with UniformValue.writeTo and uploads it
to a GpuBuffer once, at load, never to be written again. The per-entry
name in the JSON is read by no codec — only the type and the value are, and
members match the GLSL block positionally. Only the block’s own name, the key
in the uniforms map, has to match anything.
That is why the blur’s working radius is not one of them. blur.json declares a radius of zero, and box_blur treats anything under a half as “ask elsewhere”: it falls back
to a member of the Globals
block,
which GlobalSettingsUniform.update rewrites every frame — from
OptionsRenderState.menuBackgroundBlurriness, in this case — and
RenderSystem.bindDefaultUniforms binds to every post pass. Anything a
chain needs to vary per frame cannot be a chain uniform. It has to come in through a block the game writes itself — the global block, or the pass’s own SamplerInfo sizes, the only two a post pipeline declares beside its own — whose membership is fixed in Java.
The five chains
The last column is a reading of the shaders, not a citation: the GLSL says what it does to a pixel and does not say what that looks like, so those cells are the one place on this page where the book is describing rather than reporting.
| chain | who declares it | what it reads | what it looks like |
|---|---|---|---|
| blur | GameRenderer.processBlurEffect, called from inside GuiRenderer.draw | the main target and one internal target, six passes alternating between them | the world going soft behind a pause or options screen |
| creeper | GameRenderer.render, when the camera entity is a Creeper | the main target, and one internal target it bounces through | luminance collapsed into the green channel, then posterised and mosaicked |
| spider | GameRenderer.render, when it is a Spider | the main target and four internal targets | the view repeated through several skewed, blurred, red-tinted lobes |
| invert | GameRenderer.render, when it is an Enderman | the main target, and one internal target it bounces through | colours inverted, four fifths of the way |
| entity_outline | LevelRenderer.render, only on a frame where something submitted an outline | the entity-outline target, and one internal target it bounces through — never the main one | the coloured halo around a glowing mob |
Only one of those is a screen effect. blur runs over whatever is currently
on the main target, world and GUI alike, because GuiRenderer.draw splits the
GUI in two and runs this chain in the gap. Where that boundary falls, who asks
for it and why a chest does not are questions for the GUI render
tree;
what this page owns is that the thing running in the gap is an ordinary post
chain over the main target, with nothing about it that knows it is a menu.
Three are world effects: creeper, spider and invert run at the end of
GameRenderer.render’s world block, after the level and before any GUI, so they warp the world and leave the HUD alone. They get there on a list
GameRenderer.update rebuilds every frame — end_of_frame, then
LocalPlayer.getActivePostEffects, then the spectator chain unless F4 has
switched it off — which GameRenderer.render looks up and runs in that
order. No file ships as end_of_frame: ShaderManager.isPostEffectValid
logs the miss and the name is skipped until the next resource reload, so the
slot is a resource pack’s. The middle of the list is the server’s:
/posteffect (PostEffectCommand) edits a list ServerPlayer saves with
the player and sends down in ClientboundPostEffectsPacket, and the client
runs any chain on it that it has, so long as main is the only external
target the chain names. The last row is neither kind, and is the next
section’s.
The outline chain, end to end
Take the one whose whole life is visible. A mob is glowing, so something
submits it to SubmitNodeCollection.outline, and
FeatureRenderDispatcher.PreparedFrame.executeOutline draws it — flat, in
the glow colour — into the entity-outline target during the main pass. That
target is a TextureTarget LevelRenderer owns across frames.
sequenceDiagram
participant LR as LevelRenderer
participant ShadM as ShaderManager
participant PChain as PostChain
participant PPass as PostPass
participant FGB as FrameGraphBuilder
participant GR as GameRenderer
LR->>FGB: importExternal — the entity outline target, for the main pass
opt something submitted an outline this frame
LR->>ShadM: getPostChain for entity_outline, allowing main and entity_outline
ShadM-->>LR: the chain, compiled on its first ask
LR->>PChain: addToFrame with the screen size and the level's target bundle
PChain->>FGB: createInternal — the chain's own swap target, at screen size
PChain->>PPass: addToFrame, four times, in declared order
PPass->>FGB: addPass, declaring what it reads and what it writes
end
LR->>FGB: execute — with the chain added, sobel, blur, blur, blit back
GR->>LR: blitEntityOutline, after the graph — the glow onto the main target
The outline chain across one frame: declared into the level’s graph only when something glows, run when the graph executes, and composited by a separate call after the graph has finished. The four passes bounce between the outline target and the chain’s own swap target, ending where they began.
The first pass is an edge detector and what it detects edges in is alpha,
not colour: the target is transparent everywhere nothing was submitted, so
the boundary of the silhouette is the boundary of the halo. The next two blur
that outline across and then down, and the last blits it back where it
started — the chain ends on the same external target its first pass read, and
the internal swap target is what makes that legal, since no pass ever reads
and writes one buffer at once. Then it stops, and the compositing is somebody
else’s job. LevelRenderer.blitEntityOutline runs after
GameRenderer.renderLevel returns, outside the graph entirely, and blends
the glow onto the main target with a pipeline of its own. This is the one
chain whose result is invisible until a separate blit puts it on screen.
Two doors into the GPU, and one of them is deprecated
PostChain has two entry points, and which one a chain goes through is the
whole difference between the two halves of this page.
PostChain.addToFrame takes a FrameGraphBuilder somebody else already
started and appends to it. That is what LevelRenderer does with the outline
chain: it is not a second rendering path but four more passes in the graph
it was building anyway, ordered by their
declared reads and writes alongside the clear, the sky and the main pass.
They survive that graph’s culling for the ordinary reason — their last pass
writes a target imported from outside — and they appear in the profiler under
their pipeline names, because the level’s graph is executed with an inspector
that pushes a zone per pass.
PostChain.process is the other door, and it is marked deprecated. It builds
a FrameGraphBuilder of its own, imports one target as main, adds the
chain, executes it and throws it away. Both its callers are in
GameRenderer: GameRenderer.applyPostEffects, which runs the frame’s list
at the end of the world block, and GameRenderer.processBlurEffect in the
middle of the GUI.
Neither passes the inspector the level’s graph is executed
with,
so the blur and the spectator shaders never get a slice of the F3 pie chart
to themselves: their cost is folded into whichever enclosing zone they ran
under, render → world for the spectator effects and render → gui → draw
for the blur, and no name in the chart tells you a post chain is what you are
looking at. The graphs are throwaway; the memory the targets sit in is
pooled either way.
Questions players ask
Why does the creeper effect vanish when I press F5? Because third person
clears it, and the perspective key does it directly.
GameRenderer.checkEntityPostEffect switches on the camera entity’s class and
sets GameRenderer.spectatedEntityPostEffect to one of the three ids written as literals
in its own branches, clearing it for anything else — including for no entity
at all. The perspective
key calls it straight out of Minecraft.handleKeybinds;
Minecraft.setCameraEntity is the other door into the same method, for when
what you are spectating changes rather than how. F4 (Options.keyToggleSpectatorShaderEffects) is a separate
switch, flipping GameRenderer.spectatedEntityEffectActive without
forgetting which chain was chosen; the F3 screen’s DebugEntryPostEffects
lists only the chains the renderer applied, so a switched-off one drops out
of it.
What happens to a chain when the window is resized? Nothing, for the five that ship. A PostChain without a persistent target has no size of its own: the dimensions arrive as arguments to
PostChain.addToFrame every frame, and internal targets are described fresh
from them each time. GameRenderer.resize clears the resource pool, freeing the old targets at once rather than three frames later, and LevelRenderer.resize resizes the
entity-outline target it owns. The compiled pipelines never mention a
resolution, so they are untouched.
For a 1.21-era reader. The transparency chain is gone, and LevelTargetBundle.SORTING_TARGETS with it: improved transparency is
LevelRenderer.executeOit, drawn inside the level’s main pass, andGraphicsPreset.FABULOUSsetsOptions.improvedTransparencyon every platform, macOS included.PostChain,PostPass,UniformValueand the deprecatedPostChain.processkept their names.
Where to look
PostChainConfig first — the record is the file format. Then
ShaderManager.loadConfigs and ShaderManager.getPostChain for where a chain
comes from and how long it lives, PostChain.load for the validation that
decides whether it loads at all, and PostChain.addToFrame for the only
thing a chain does. PostPass is one pass and one draw. For the callers,
LevelRenderer.render declares one chain into the world’s frame graph,
GameRenderer.update lists the frame’s chains, GameRenderer.render and
GameRenderer.processBlurEffect run the rest through the deprecated door, and
LevelTargetBundle names what any may ask.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
XII · World generation
Verified against Minecraft 26.3 · Part XII · The one system in the game built to be reproducible from a world’s settings and its data packs alone, where it frays, and the single deliberate exception to it.
What a player recognises the part by is the seam: the flat shelf of ground under a village that was not there before, the cave that is flooded the moment you break into it, the desert that becomes a jungle along a ragged line, the tree that grows up through another tree.
Everything this part generates is determined by the world’s saved settings — the seed, the dimension list and a few options such as whether structures generate — and the data packs enabled when the world is opened: WorldLoader.load re-reads the worldgen registries out of the current packs every time. No entity and no tick is among those inputs. Give the same settings and the same packs to two copies of the game and they will agree on every biome, every structure’s place and every block of terrain — which is the property speedrunners, seed-hunting sites and structure finders all depend on. It holds
not because nothing here reads the world — the decoration step reads block
states and heights through PlacementContext
(features and placement), and the
carvers read a whole neighbourhood of chunks for the biome that seeds each
cave (terrain) — but because everything it reads is itself a function of those settings and those packs. Two edges fray. Decoration writes into its neighbours, and no rule orders a chunk’s decoration against a neighbour’s, so what a feature finds at a chunk border can depend on which of the two decorated first (what a feature may write); and a template a structure block saved into the world is tried before the packs’, so every later piece that names it, and the ground a beard shapes round that piece, follows the saved file (where a template comes from). And there is one deliberate exception, where generation reads chunks the current seed did not produce: chunks an older version generated.
Counting world/level/levelgen and world/level/biome together, the way
the atlas counts everything else, that is
477 files and 49,303 lines — and two of them are the standing counter-example to the paragraph above. PatrolSpawner and
PhantomSpawner sit in these packages, run on the server tick, and belong to
Parts III and VI.
The shape of the part
Part XII is a substrate, a pipeline, and a wing. The substrate is the scalar field every terrain step samples; the pipeline is the chunk being made, step by step; the wing is the three structure lectures, which run first in the game and are taught last here, before only lecture ten. Part IV owns the conveyor that runs the statuses (the chunk generation pipeline); this part is the cargo of five of them.
The numbers below are the order to watch and the arrows the order the game runs, and the disagreement between them is the argument for the order.
flowchart TD
CW["10 · Creating a world"] -.->|"chose the settings and packs"| DF["1 · Density functions"]
DF -.->|"is sampled by"| L2
subgraph SS["the structure statuses"]
S7["7 · Structure placement"] --> S8["8 · Jigsaw and templates"]
S7 --> S9["9 · Hand-built structures"]
end
SS --> L2["2 · Biomes, at BIOMES"]
L2 --> TE
subgraph TE["TERRAIN"]
L3["3 · Terrain"]
L4["4 · Blending"]
end
TE --> FE
subgraph FE["FEATURES"]
L5["5 · Features and placement"] --> L6["6 · Trees"]
end
SS -.->|"pieces become blocks"| FE
The part laid against the chunk-status ladder and numbered to the watch
order; a solid arrow is the order the game runs, a dashed one says what it
means in its label. The structure arc, at ChunkStatus.STRUCTURE_STARTS and
ChunkStatus.STRUCTURE_REFERENCES, is first in the game and, before lecture ten, last in the lectures.
A structure is decided at ChunkStatus.STRUCTURE_STARTS, two statuses before
the biomes it will stand in exist, and writes no block until
ChunkStatus.FEATURES, one status after the noise fill: told in run order it is two lectures with biomes and terrain between them, and told last it is one arc — the
dashed arrow from the structure box to ChunkStatus.FEATURES is that gap. The
arrow out of lecture ten is the same trade at the other end. Both cost one thing, a forward
topic — two of the six pages before the structure arc reach for the beardifier
before the page that owns it
(structure placement).
The arrow out of lecture one means is sampled by rather than happens before:
the biome step is the first status to sample the field across a whole chunk,
through a caching Climate.Sampler of its own. The decoration and structure packages never mention DensityFunction at all, and reach the substrate only through the beardifier, the heights the generator hands them and the Climate.Sampler a structure’s biome test reads — the generator’s own for a structure start, and one that StructureCheck and JigsawPlacement.generateJigsaw each build for themselves
(density functions).
Before you start
The chunk generation pipeline from Part IV, and not optionally. It is the only page that says when any of this runs, what the ten chunk statuses are, how the dependency pyramid keeps neighbours out of each other’s way, and which thread each step is on. Eight of the ten pages here name a status, and two of them open on one.
Chunk anatomy, for what is being written into — sections, the two paletted containers, and the heightmaps the terrain steps maintain by hand. And environment attributes and timelines, also from Part IV, for lecture two: the sky, the fog, the music and twenty-three gameplay attributes come out of a stack of modifier layers in which the biome is one layer rather than the owner.
Codecs, NBT and JSON and identifiers and registries from Part II, because worldgen is the most thoroughly data-driven system in the game and this part assumes the dynamic-registry model rather than re-teaching it. The data-driven type pattern lists fifty-six instances of the pattern, and twenty-six of them are owned by a page in this part — more than any other part of the book.
Watch in this order
- Density functions — the substrate, and the abstract one. Three forms of one graph, two rewrites, and one kind of cache that caches nothing until something else grants it.
- Biomes — a nearest-neighbour search in seven dimensions, one of which is not sampled from the world at all, and the two biome borders the game keeps a couple of blocks apart.
- Terrain — noise, surface and carvers. Seven hundred and sixty-eight cells filled from their corners, and a cave whose water was decided before the cave was.
- Blending at the old-chunk border — the part’s deliberate exception, where generation reads chunks an older version left. Sixteen columns an old chunk measures out of its own blocks, and a seam where the terrain splines have no say.
- Features and placement — decoration as a stream of positions folded through filters, in one order the whole dimension shares, sorted once — and which a data pack can make impossible.
- Trees — one algorithm with five slots in it, and the clearance scan that runs after the crown has been sized.
- Structure placement — the part’s policy page. A lottery that never looks at the world, an absence stored as a hole, and a command that generates chunks to answer a question.
- Jigsaw and templates — how a village assembles itself, and how any piece becomes blocks. A growth limit that takes the right pool away a piece before it stops the queue.
- Hand-built structures — the older assembler, which is still most of the code. Four families of piece grammar, and the one structure that throws itself away and starts again.
- Creating a world — where the seed and the data packs came from. A screen that is a running data-pack load with widgets on it, settings carried across a reload by being serialised to JSON, and a Cancel button that does not undo.
Watch one to six straight through: that is the chunk being made, in the order it is made, and each page from two on leans on one before it. ChunkPyramid makes
ChunkStatus.BIOMES a requirement of ChunkStatus.TERRAIN, so two really
does come before three, and four needs both. Then stop and read seven, eight and nine as one arc — eight and nine are
alternatives to each other, not a sequence, so either order works. Ten is the
only page that moves: a viewer who wants the origin before the machinery can watch it first, at the cost of meeting the generators, biome sources and features it names before their lectures.
Where the part stops
At ChunkStatus.FEATURES. What happens to a chunk after that — lighting,
spawning, promotion to a live chunk, and being saved — is Part IV; what the
client is told about any of it is Part IX, and the answer is the finished
chunk with none of the machinery.
It stops in a second direction too:
17% of the part’s lines are named on no page in the book, and almost all of them are
one shape — an algorithm that writes blocks and adds no mechanism.
Fifty-eight of the sixty-seven classes in the feature package are one
Feature each — icebergs, geodes, lakes, coral, the End spikes — and beside them five smaller families of the same kind: the block predicates, the height providers, the state providers, the rule tests and the material rules and conditions, whose members are combinators over a shape one of
these pages already teaches. Naming them would be a catalogue, and
what this book skips declines it. What
the part owes, and pays, is the head of each family — on
features and placement,
trees,
terrain,
its surface pass and
jigsaw and templates.
Reference this part uses
Density-function nodes is the catalogue behind lecture one: all forty-four node types, what each takes, what the compiler turns it into, what range each reports, and which ids the shipped data writes. Beside it, registries for the seventeen worldgen/ registries a data pack writes into; structure spawn overrides for the six structures whose JSON replaces the biome’s spawn list, which lecture seven describes and Part VI runs; level data and rules for which file the seed, the dimensions and the rules end up in, which lecture ten links to rather than restates; and math and primitives for the two random families the determinism this page opens on lives in. Diagram lanes and the twenty-five rows naming drift keeps for this part are the two every part can use. And the glossary holds this part’s twenty-five headwords, which is where to go first if a page above spends one before you have met it.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Density functions
Verified against Minecraft 26.3 · Part XII · One number out of one point: how a JSON file becomes “stone or air”, and why the graph you can read in the registry is never the graph that runs.
Open the overworld’s depth function in a data pack and you can read the shape of the world out of it in twelve lines: a gradient down the Y axis, added to overworld/offset — which is fifteen hundred lines of continents and erosion wrapped in a node called cache. Both files are honest and readable. And the object it parses into is never sampled by anything, because it has no method that answers a number for a position: it is rewritten and compiled per dimension, and only the compiled form ever computes a number for a block. The cache named in that file caches nothing. It is a request, and something else grants it.
Terrain is a scalar field: a function from a block position to a
float, where the convention is that zero is the surface and positive
means solid. DensityFunction is the interface, DensityFunctions is the
library of factories you build one out of — the node classes themselves sit in
the generator and op packages under it — and a data pack assembles them
as JSON. Forty-four ids are registered, which is not the same number as the
classes behind them: eleven of them are one UnaryFunction, six are one
BinaryFunction, four are one RoundFunction, three are one
SimpleDensityFunction, and old_blended_noise is BlendedNoise, which
lives with the noises in the synth package. This page is the three forms
that one graph takes and the machinery that moves between them; the node types
themselves are
the node catalogue, and
the terrain steps that sample the result are
terrain.
Nothing here touches a block, and nothing here differs between two worlds built from the same settings and the same packs. This is the layer that the seed means.
The cast
| class | what it owns | its clock |
|---|---|---|
DensityFunction | the description, which answers no number for a position: DensityFunction.compileSampler builds its sampler, DensityFunction.rewriteChildren lets a rule rebuild it, and DensityFunction.range and DensityFunction.domainAxes are the static analysis | — |
DensityFunctions | the factories, the registration of the node types, and the codecs that dispatch a JSON type to one of them | start-up, then data-pack load |
DensitySampler | the compiled form, the only one that answers a number: DensitySampler.sampleValue for one point, DensitySampler.sampleVolume for a whole DensityVolume at once | compiled once per dimension |
CacheFunction | a cache request, wrapping one function; it refuses to compile | replaced once per dimension |
NoiseRouter | the eight functions a generator asks for, as one record; NoiseRouter.createClimateSampler turns six of them into a Climate.Sampler | — |
NoiseRouterData | vanilla’s graph, written in Java and emitted as the JSON that ships | build time, and at run time for NoiseRouterData.none and NoiseRouterData.peaksAndValleys |
RandomState | the per-dimension instantiation: the seed’s random factories, the noise memo, the DensityFunctionCompiler, the MaterialSystem | once per level |
NoiseChunk | one run of the graph: its DensityVolume — the chunk’s, or one column’s for a height query — its aquifer, and the one SamplerContext every sampler it hands out is bound to | once per chunk, and once per height query |
Underneath all of it is the synth package: NormalNoise (a noise
definition — octaves, amplitudes, normalisation — which NormalNoise.create
seeds into one NoiseStack of paired PerlinNoise octaves), PerlinNoise
(one octave of 3-D Perlin over GradientNoise’s permutation table),
NoiseStack (octaves summed, each at its own frequency and amplitude),
BlendedNoise (the pre-1.18 terrain noise, itself a density function node,
built from SmearedPerlinNoise octaves), LegacyFbmInitializer (the old
construction the two nether climate noises keep), and SimplexNoise, which no
noise definition reaches: the End islands node uses it directly, and the
fixed-seed noises Biome keeps for its own per-block temperature are built
from it (biomes). Every seeded noise is a Noise, and with NoiseUtils that is the whole package. Noises, one package up, holds the sixty-four keys for the definitions, and Noises.instantiate seeds one from a positional factory.
Three forms of one graph
flowchart LR
P["as parsed: pointers, cache requests, no seed"] -->|"rewritten"| O["as optimised: pointers inlined, caches numbered"]
O -->|"compiled"| C["as compiled: one DensitySampler tree"]
C -->|"bound, per run"| B["bound to one SamplerContext"]
One graph, three forms and a binding: the rewrite and the compile happen once per dimension, and every run binds the same compiled tree to a context of its own.
What changes between the forms is four kinds of node and any subgraph that ignores an axis, and the rest of the graph — an add, a gradient, a spline — comes through the rewrite as it was.
| the node | as parsed | as optimised | as compiled |
|---|---|---|---|
a pointer at another entry, DensityFunctions.HolderHolder | a pointer | the graph it pointed at | that graph’s sampler |
a cache request, CacheFunction | refuses to compile | a numbered DensityFunctionCompiler.PreparedCache, one per distinct input | a CachingDensitySampler that asks the context for its cell |
a seeded leaf: NoiseFunction, and the shift, blended-noise and end-islands nodes | a definition, no seed | unchanged | seeded from the compile context |
the blend and beardifier leaves, SimpleDensityFunction | three singletons | unchanged | a ContextBoundSampler that asks the context, else answers 1, 0 or 0 |
| a subgraph that ignores an axis its parent uses | as written | wrapped in a SliceFunction at zero on that axis | computed once, copied along that axis |
| who samples it | nothing | nothing | everything, each against its own SamplerContext |
The first two arrows are where the graph changes. A DfRewriteRule takes a
node and returns one, reaching its children through
DensityFunction.rewriteChildren, which rebuilds a node when a child comes
back different; the compiler runs two in sequence — its own, which
inlines every pointer and numbers every cache, then
DfRewriteRule.SLICE_UNIFORM_AXES. Then DensityFunction.compileSampler
turns what is left into samplers, each node building its own from its
children’s. The third arrow changes nothing in the tree: it pairs it with a
context, and everything a chunk adds — its caches, its blender, its
beardifier — arrives that way.
Parse: one file, one graph
Every file under a pack’s worldgen/density_function directory becomes one
registry entry through DensityFunctions.DIRECT_CODEC, which is an either:
a bare number in the JSON is silently a ConstantFunction, and
anything else dispatches on its type id through
BuiltInRegistries.DENSITY_FUNCTION_TYPE — the ordinary shape of a
data-driven type, and the reason a pack can write a new graph but not a new
kind of node, because that registry is frozen at start-up
(the data-driven type pattern,
the freeze rule).
Every child slot instead uses
DensityFunction.CODEC, a RegistryFileCodec, so a string id, an inline
object and a bare number are interchangeable everywhere a function is
expected — and a RegistryFileCodec is exactly the kind that cannot resolve
an id without a registry context to read it against
(codecs, NBT and JSON).
A string becomes a DensityFunctions.HolderHolder — a live pointer
at another entry, which is how a graph references a graph.
Two things a reader of the JSON cannot see happen at the compile; construction
keeps every node as the file wrote it. The compiler folds:
BinaryFunction.compileSampler turns a two-argument node with one constant
argument into a sampler that carries the constant, such as BinaryFunction.ConstAddSampler, unless a min or max has already been settled from its bounds, so a node type in the file is not
necessarily the sampler that runs. And it reads the bounds, a static analysis of the data pack: DensityFunction.range answers from a node’s children without
a position — one that talks back, because compiling a min or a max over
two ranges that cannot possibly overlap logs a warning naming both arguments,
and compiles only the side that wins whenever the inputs stay inside their declared ranges.
DensityFunctions.HolderHolder is the one parsed node that cannot be written back
out: it is not registered, and asking it for its codec throws. It exists only
in memory, and re-serialising a graph goes through DensityFunction.CODEC,
which recognises it and emits the id string it came from.
Seed: once per dimension
RandomState.create forks the seed into one positional factory — from which
RandomState.getOrCreateRandomFactory hands out named ones, aquifer and
ore among them, each a factory of XoroshiroRandomSources unless the settings ask for the legacy family
(math and primitives)
— and builds a DensityFunctionCompiler, rewriting nothing: the first
RandomState.getSampler for a function rewrites and compiles it, once for the
dimension.
The eight router fields partition cleanly by consumer, and the partition is
the map of the rest of the part: six are the climate sampler’s (biomes),
one — chunk_surface_level — is the surface rules’, and the last,
final_density, is the one the fill samples a chunk from
(terrain). The aquifer
and the ore veins read functions of their own, from the settings’ aquifers
block and the two ore-vein material rules; the aquifer’s surface_level,
sampled on a quart grid, is how it finds the ground it should put a water
table under. Nothing in
the decoration or structure packages ever mentions DensityFunction at all; they reach the substrate only through the beardifier, the heights the generator hands them and the Climate.Sampler a structure’s biome test reads — the generator’s own for a structure start, and one that StructureCheck and JigsawPlacement.generateJigsaw each build for themselves.
A density function is seeded inside the compile and nowhere else, through the DensityFunction.CompileContext that RandomState builds: a noise leaf
gets a seeded Noise from RandomState.getOrCreateNoise, a memo keyed by the
definition’s id; BlendedNoise gets a random source forked by the name terrain, or under the legacy family a LegacyRandomSource on the bare seed; the end-islands node gets a LegacyRandomSource on the bare seed.
The parsed graph itself is never touched.
Two details in there matter later. The two nether climate noises are
special-cased into a legacy construction over a LegacyRandomSource and
therefore skip the memo entirely. And the compiler keeps its own memo of the
caches it has prepared, keyed by what each one wraps, so a cache reached from several router fields is prepared once and stays one object — which is precisely what makes the per-chunk caching in the next step pay, because router fields that share a cache share its number, and a number is one cell in
a context.
The memo goes further than that, because it keys on the wrapped node itself and the nodes are records: two separately parsed but structurally identical cache requests are equal, and so are merged into one object with one cache.
The compiled form is the only one anything ever samples, and away from the
fill it is sampled in production: the biome step, the biome searches, the structure starts and checks, the height queries behind ChunkGenerator.getBaseHeight, the spawn search and the F3 noise readout, which
NoiseBasedChunkGenerator.addDebugScreenInfo fills from the settings’ own
debug_functions list, all run the dimension’s compiled samplers against
contexts of their own. That is the whole reason a compiled sampler has to stay
safe to run against any context, down to SamplerContext.EMPTY_UNCACHED, which
holds no cache and no field at all. No second graph is built for the climate
either: RandomState.createClimateSampler binds the six climate fields’
samplers to whatever context its caller brings, and that is the
Climate.Sampler biomes
reads.
Wrap: once per chunk
NoiseBasedChunkGenerator.createNoiseChunk builds the workspace and rewrites
nothing; the SamplerContext the NoiseChunk constructor builds is the
switch that matters. It has caches on, buffers from a DensityBufferPool
borrowed from RandomState, and user fields that carry this chunk’s world:
the chunk’s Beardifier under Beardifier.CONTEXT_KEY and, when the step’s
Blender is not empty, the blender under Blender.CONTEXT_KEY and two
samplers under Blender.ALPHA_KEY and Blender.OFFSET_KEY, over an alpha and
an offset the constructor has already measured for the whole volume, before
anything is sampled (blending).
Every sampler the chunk hands out is the dimension’s compiled one, wrapped in a
DensitySampler.Bound with that context. The three SimpleDensityFunction
leaves look their key up on every call and fall back to 1, 0 and 0 when it is
missing, and a blend_density node passes its input through when the context
has no blender.
So a cache written into a data pack does something, but not by itself: it
is a request that the compiler give that slot a number, and the node itself
cannot even be compiled. The cell behind the number is the context’s, and a
context built without caches, such as SamplerContext.EMPTY_UNCACHED, grants
none. Worldgen performance lives in the compiler and the context, not in the
node.
The fill samples the router’s final_density as the data pack wrote it, one
volume for the whole chunk
(terrain
walks the loop that reads it). The beardifier is in the data too: every
shipped router’s final_density ends in an add with a beardifier node,
and the chunk only puts a Beardifier into the context, so a noise
dimension is beardified only if its router’s graph has a beardifier node in
it, and “structures flatten terrain” is implemented as a context lookup
inside one leaf.
Nothing here needs reversing: the registry keeps the graph as parsed, and it
serialises back unchanged, while DensityFunctionCompiler.PreparedCache
refuses to encode and the compiled form is not a DensityFunction at all.
The one cache, and what a single point may use
This is the payoff of the whole arrangement: the data asks for one kind of cache, and what that cache does is decided by the context it runs in and the call it answers.
Each cache number is one cell in a context that enables caches, and the cell
answers two calls. SamplerContext.sampleVolumeCached answers a volume: the
first request samples the whole volume into a buffer from the context’s arena,
and every later request for the same volume gets a copy, until a different
volume replaces it. SamplerContext.sampleValueCached answers one point —
from the one point the cell last computed, else from the cell’s buffer when
the point lies on its volume, else by computing it and remembering it. A
context built without caches has no cells, and every cache computes straight
through.
A single-point sample is not a cache bypass — it reads the volume a cache
already holds, when the point lies on it. A volume can be coarser than the
blocks it covers, as the corners of interpolation cells are, and then a point
on its grid is a hit while a point between is computed. Every
sampler carries both calls, DensitySampler.sampleValue and
DensitySampler.sampleVolume, and the volume is what the fill drives: one
call for the chunk’s whole volume, each node filling a buffer from its
children’s.
The resolutions are worth saying once. interpolated is not a cache at all:
it carries its own cell size — four by eight by four under the overworld’s
and the Nether’s final density, eight by four by eight under the End’s and
the floating islands’, sixteen by one by sixteen for chunk_surface_level —
samples its input at the cell corners, and fills the blocks between by
trilinear interpolation. The per-column saving is no node’s:
DfRewriteRule.SLICE_UNIFORM_AXES wraps every subgraph whose
DensityFunction.domainAxes lack an axis its parent’s have, constants and
gradients aside, in a SliceFunction at zero on that axis, so a
two-dimensional term inside a three-dimensional graph is computed once per
column of whatever volume is asked for. And vanilla spends the one cache
freely: thirty-two cache nodes sit in the shipped density_function files,
and no noise_settings file contains one at all.
What a bound is worth, and which form told you
DensityFunction.range is answered without a position, which makes it a
static analysis of the data pack — and the analysis is of a graph that will
be rewritten and compiled before it runs. Which individual nodes report
something other than their child’s range is the catalogue’s
(the node catalogue). What
this page owns is what the compiler does with a bound — settles a min or a
max from it, and hands the sampler the other side’s limit so that a single
point can skip that side — and that every form that reports a bound reports the same one.
Seeding changes nothing. A noise leaf answers from its definition, not
from a seeded noise: NormalNoise.range is fixed when the definition is
decoded, and every one of the sixty-four shipped noise definitions reports a
symmetric bound, from ±0.87 to ±5.73, before any seed exists. A freshly parsed
router therefore reports the bounds the compiled one runs with.
Nor does the context. The three leaves a context can replace declare the range of what replaces them — zero to one for blend_alpha, unbounded for blend_offset and the beardifier — and their fallbacks, 1, 0 and 0, sit inside it. So a min or a max the compiler settles above a blend leaf is settled on the range the running graph has.
What nothing reaches
Among the names in this system that read as machinery and are reached by nothing, two are worth a warning, and one of them is the most misleading thing in the catalogue.
DensityFunctions.shift, the three-dimensional domain warp, is written by no
shipped file: vanilla uses only the two two-dimensional warps, which read the
same noise parameters with their axes swapped, behind a registry id called
offset rather than shift
(which ids vanilla uses).
Beside it, NoiseUtils.biasTowardsExtreme is a curve with no callers in a package where every other class builds or samples a noise. Density cannot join them: its three values are compile-time constants, which javac writes into whatever uses them, so the decompile cannot say who reads them — but it names the three conventions this whole system rests on: surface at zero, and the 64 and −64 a node reaches for when it wants to end an argument.
The nodes that read the world
Everything in the catalogue — every noise, spline, selector and cache — reads
no block, no chunk and no level, which is why the whole system can run on the worker pool with nothing loaded, one chunk-status task at a time
(the chunk generation pipeline).
Four nodes from two sources are the exception, and all keep the property anyway by the same trick: they read only what the sampling context carries, and everything in it
was harvested before the first sample. The three blend nodes reach
BlendingData through the blender
(blending at the old-chunk border);
the beardifier node reads a Beardifier, whose
Beardifier.forStructuresInChunk reads the structure references out of chunks
at ChunkStatus.STRUCTURE_REFERENCES
(structure placement).
That is what deterministic means here, stated once: the only two sources anything in the graph reads hold work done before the first sample — structures the same seed and the same packs placed, and, beside a chunk an older version left, what that chunk’s blocks measure — so the graph is a function of the seed and the packs everywhere except beside such a chunk, which is the part’s one deliberate exception (world generation).
For a 1.21-era reader. The cache markers flat_cache, cache_2d, cache_once and cache_all_in_cell are gone, and cache does their work; DensityFunction.compute is gone, and
DensitySampler.sampleValuedoes its work on the compiled form.
Where to look
Read DensityFunction first — the interface is DensityFunction.compileSampler,
DensityFunction.rewriteChildren and the static analysis, and everything else
is a node — and then DensitySampler, the form that answers. Then
DfRewriteRule and DensityFunctionCompiler, which are where the graph
changes, with CacheFunction and
DensityFunctions.HolderHolder as the two node types the rewrite exists to
replace. DensityFunctions.DIRECT_CODEC is the parse;
DensityFunctionCompiler.getSampler and the NoiseChunk constructor are the
compile and the binding, in that order, and reading them side by side is the
page. NoiseRouter names most of what they compile. Finish inside
SamplerContext, where the cache cells live, and in
NoiseRouterData.registerTerrainNoises for the graph vanilla ships.
One door the page never opens: Noises.instantiate, which is where a noise
definition becomes a seeded Noise.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Biomes
Verified against Minecraft 26.3 · Part XII · A point in the world gets a biome: six numbers quantised to integers, a nearest-neighbour search in seven dimensions, and the two different answers the game keeps for the same block.
Walk out of a desert into a jungle and watch the ground. The grass changes colour along one line, softened over a few blocks. The fog and the sky change along a different line, a couple of blocks away, and fade across it over a band several times as wide. Neither is a bug and neither is a rendering artefact: the game genuinely stores one biome per four-by-four-by-four volume and then answers “which biome is this block in?” two different ways, one jittered and one not, and different systems ask different questions. The surprise is which side each thing is on — grass colour, mob spawning and whether water freezes are all on the jittered side, and the sky is not.
A biome is a label in the chunk at quarter resolution — one per
four-by-four-by-four block volume, which this book calls a quart cell (the code’s quart) — plus a bundle of consequences hanging off that label. The
bundle is a thin one: Biome itself holds four things, and most of what a
player would call “the biome” lives in the environment-attribute stack, where the biome is
one layer among several rather than the owner
(environment attributes and timelines).
The cast
| class | what it decides | when |
|---|---|---|
BiomeSource | which biome a quart cell gets, through the BiomeResolver it makes for the chunk fill. BiomeSources.bootstrap registers four — MultiNoiseBiomeSource, TheEndBiomeSource, FixedBiomeSource (one biome everywhere, which is what a Single Biome world is, and what a superflat and a Debug Mode world use too) and CheckerboardColumnBiomeSource (a listed few in squares) — and BiomeSource.possibleBiomes is the memoised pre-filter everything else leans on | ChunkStatus.BIOMES, on the worker pool |
Climate.Sampler | the six climate numbers at a point — temperature, humidity, continentalness, erosion, depth and weirdness. RandomState.createClimateSampler binds the six to a sampling context, with or without caches: filling a chunk takes a caching one, whose functions MultiNoiseBiomeSource.createResolverForChunk samples over the whole chunk at once, and BiomeSource.createCachingResolver and BiomeSource.createUncachedResolver wrap one for a lookup outside the fill (density functions) | once per chunk filled, or per point asked |
Climate.ParameterList | the search space: Climate.ParameterPoints paired with biomes, indexed by a Climate.RTree | built at each world load |
OverworldBiomeBuilder | the overworld’s parameter table, in Java — temperature, humidity, erosion and continentalness bands over six tables of biome keys | each world load |
LevelChunkSection | where the answer lives: a second PalettedContainer keyed by biome holder, two bits per axis | written at generation, saved, shipped |
BiomeManager | the jitter — which biome this block gets, as opposed to which cell it is in | every gameplay read |
Biome | four things: climate settings, an EnvironmentAttributeMap, BiomeSpecialEffects and generation settings | — |
EnvironmentAttributeMap | the biome’s contribution to sky, fog, music, the spawn lists and the gameplay switches — as modifiers, not values. Twenty-three gameplay attributes exist; the sixty-seven vanilla biome files touch five of them between them — every file sets the spawn lists, and for fifty that is the only one | per attribute, per read |
Deciding a chunk’s biomes, cell by cell
sequenceDiagram
participant NBC as NoiseBased<br/>ChunkGenerator
participant MNBS as MultiNoise<br/>BiomeSource
participant ClimS as Climate.Sampler
participant CA as ChunkAccess
participant LCS as LevelChunkSection
participant CPList as Climate.<br/>ParameterList
Note over NBC,CPList: ChunkStatus.BIOMES, forked to the worker pool as createBiomes
NBC->>MNBS: createResolverForChunk, with a caching Climate.Sampler
MNBS->>ClimS: each of its six functions, sampled over the whole chunk
ClimS-->>MNBS: a buffer per function, one value per quart cell
MNBS-->>NBC: a resolver that reads the six buffers
NBC->>NBC: decorateBiomeResolver, wrap the resolver twice
NBC->>CA: fillBiomesFromNoise, with the wrapped resolver
loop per section
CA->>LCS: fillBiomesFromNoise, into a new container
loop per quart cell, 64 of them
LCS->>MNBS: getNoiseBiome, through the resolver
MNBS->>CPList: findValue, the six buffered values as a Climate.TargetPoint
CPList->>CPList: findValueIndex, the search down its Climate.RTree
CPList-->>MNBS: the nearest biome holder
MNBS-->>LCS: into the new container
end
end
A chunk’s biomes are the six climate functions sampled once over the whole chunk, then 64 searches per section, each a lookup into those samples and a nearest-neighbour walk; no block is read and none is written.
The two wrappers NoiseBasedChunkGenerator.decorateBiomeResolver adds only do anything beside or inside chunks an older version generated: the blender’s beside them (blending at the old-chunk border) and BelowZeroRetrogen’s inside one being deepened (the other passenger).
Biomes are decided before terrain, and not for its shape. ChunkPyramid makes
ChunkStatus.BIOMES, one chunk out, a requirement of ChunkStatus.TERRAIN,
so the order is enforced — but what the terrain step collects from it serves its surface pass alone: the biomes in the three-by-three chunks’ palettes (the carvers work theirs out again from the biome source).
The noise fill never reads a biome. What makes a jungle and its
terrain agree is that both were computed from the same noise router:
four of the six functions in the climate sampler RandomState.createClimateSampler binds are the very depth, continents, erosion and ridges functions that shape the land — under the
Climate.Sampler names depth, continentalness, erosion and weirdness.
Neither was consulted about the other. The biome does not touch a block until
the surface pass, which follows the fill inside the same ChunkStatus.TERRAIN
task (terrain).
The fork at the top of that trace is not the noise generator’s:
ChunkGenerator.createBiomes is the base class’s alone and forks to the worker pool, as createBiomes, for every generator, so a superflat world leaves the worldgen
executor for its biomes exactly as the overworld does. What
NoiseBasedChunkGenerator overrides is one step,
ChunkGenerator.decorateBiomeResolver, for its two wrappers; FlatLevelSource
and DebugLevelSource keep the base one, which wraps nothing. Sampling the
whole chunk up front is the biome source’s choice, not the generator’s: only
MultiNoiseBiomeSource overrides BiomeSource.createResolverForChunk, and the
other three sources answer cell by cell through BiomeSource.createResolver.
The End is a NoiseBasedChunkGenerator like the
overworld and the nether — just with TheEndBiomeSource in front of it,
which does not do a climate search at all: it thresholds a single erosion
sample outside a fixed central radius.
The search, and the axis that is not sampled
Climate.quantizeCoord multiplies each of the six climate values by ten
thousand and truncates, so the entire search is integer arithmetic. The
target is a Climate.TargetPoint of six longs; each entry in the list pairs a biome with a Climate.ParameterPoint of six Climate.Parameter intervals, and most biomes have several; and
Climate.ParameterList.findValue walks a Climate.RTree — up to nineteen
children per node — minimising the sum of squared distances from the target
to each interval.
Except the count is seven, not six. Climate.PARAMETER_COUNT is 7 and
Climate.RTree.create refuses a point that does not supply seven, because
each Climate.ParameterPoint carries a scalar offset alongside its six
intervals, and Climate.TargetPoint.toParameterArray appends a literal zero
as the seventh coordinate of every query. So the seventh term of the metric
is always that biome’s offset squared: a fixed penalty added to its score. It
is a “make this biome harder to win” dial, not anything sampled from the
world.
The tree also remembers. Climate.RTree keeps the winning leaf in a
ThreadLocal and seeds the next search with it as the initial candidate.
Adjacent quart cells almost always resolve to the same biome, so the walk
usually prunes immediately — which is what makes filling sixty-four cells a
section cheap. The tree is therefore stateful per thread, though never
incorrect: the remembered leaf is only a starting bound.
Which is also the answer to whether a pack can move the overworld’s biomes
around: not the vanilla preset. OverworldBiomeBuilder is hardcoded Java, and
the data-pack element that would carry it,
MultiNoiseBiomeSourceParameterList, serialises to nothing but a preset name —
MultiNoiseBiomeSourceParameterLists registers two. A pack may supply its own
parameter list inline for a multi-noise source; it cannot edit the one that
ships.
One thing the parameter table does not have is a separate underground
system. OverworldBiomeBuilder.addUndergroundBiomes and
OverworldBiomeBuilder.addBottomBiome place dripstone caves, lush caves,
sulfur caves and the deep dark into the same seven-dimensional table by their
depth band. A
cave biome is an ordinary entry that happens to win only below the surface.
The two borders
The label goes into the section’s second paletted container — two bits per
axis, so sixty-four biome cells per section
(sections and their four counters) —
and is shipped to the client
inside the chunk payload. From then on it is stored, not computed, which is
why FillBiomeCommand can exist at all and why ClientboundChunksBiomesPacket
exists to tell the client about the result.
And then two different readers ask for it two different ways.
| the jittered read | the exact read | |
|---|---|---|
| entry point | LevelReader.getBiome → BiomeManager.getBiome | BiomeManager.getNoiseBiomeAtPosition, and on the client BiomeManager.getNoiseBiomeAtQuart |
| what it does | offsets by two, takes the eight surrounding quart corners, and picks the one minimising BiomeManager.getFiddledDistance — a seeded hash worth up to ±0.45 of a cell per axis | floors to the quart cell and reads the palette |
| who uses it | freezing and precipitation, mob spawning, commands, the surface rules during generation — and block tint: grass, foliage and water colour, through ClientLevel.calculateBlockTint | the environment-attribute stack, for every attribute but the spawn lists — EnvironmentAttribute.isFullResolutionBiomes sends those to the jittered read — and nothing else that goes through BiomeManager |
| what it looks like | the ragged border | the straight one |
Block tint is on the jittered side, which is the half of this that surprises people: grass colour comes from the same jittered read as whether snow falls. What softens the colour boundary in game is not the biome lookup but a box blur on top of it, which the client owns (the client level). Fog and sky are the ones on the other border.
The client’s exact read is the more expensive of the two, and it is spent unconditionally: the probe that feeds the attribute stack runs its Gaussian pass over the neighbourhood every tick whether or not any attribute at that position is interpolated at all, because the test is applied later, when the layer is applied (the same value on the client). The server never interpolates and never pays it — it passes no interpolator.
What a biome still owns
Four things, and none of them stops being read once the chunk is generated —
NaturalSpawner reads the spawn attribute every tick and a bone-mealed grass block
asks the generation settings.
Biome.climateSettings is precipitation, a base temperature, a
Biome.TemperatureModifier and downfall. Temperature is the interesting one:
Biome.getHeightAdjustedTemperature samples noise per block high above sea
level — which is why snow lines are uneven rather than flat — and Biome
keeps a fixed-size per-thread cache in front of it that evicts rather than
grows. Most of the public surface is the questions rather than the number:
Biome.warmEnoughToRain, Biome.coldEnoughToSnow, Biome.shouldFreeze,
Biome.shouldSnow. Biome.getBaseTemperature is the raw, uncached,
unadjusted escape hatch.
BiomeSpecialEffects is only block tint — five fields, all of
them colours or a grass-colour modifier, and only the water colour is
mandatory. Fog, sky, clouds, ambient sound, music and particles are attributes instead. What is left is read by nothing in the attribute
system at all: BiomeColors reaches these five fields through four
ColorResolvers, with no probe and no layer stack anywhere in the path
(lightmap, fog and sky). And when the
four optional ones are silent, the grass and foliage tints do not come from the effects at all: they are a lookup into the colormap images by the biome’s base temperature and downfall, through GrassColor, FoliageColor and DryFoliageColor. “The
biome’s grass colour” is usually just the two climate numbers that index a
texture.
Biome.getAttributes returns the EnvironmentAttributeMap, and what a biome
puts in it are modifiers, not values — which is how a swamp thickens water
fog without naming a distance
(arguments, not values).
One restriction lands here specifically:
EnvironmentAttributeMap.CODEC_ONLY_POSITIONAL means a biome may not set a
non-positional attribute at all. And one cost lands on the whole dimension:
a positional layer is built per attribute that any biome in the registry
mentions, at level construction
(the stack a value falls through),
and a layer is not free where the biome that wanted it is absent — a biome that names an attribute no other biome does adds a layer every position in every dimension then falls through.
BiomeGenerationSettings holds two lists, and they are read by different
pages: the placed features, one set per decoration step, by
features and placement,
and the carvers by terrain, at
a different status. BiomeGenerationSettings.getBoneMealFeatures is its only
reader outside worldgen, and its one caller is GrassBlock. The spawn lists
are in the attribute map, as EnvironmentAttributes.NATURAL_MOB_SPAWNS, which
every biome file sets with a MobSpawnSettings argument and NaturalSpawner
reads through the attribute stack — though a structure at the position can
replace them outright before the spawner ever looks
(a spawn attempt is a filter) —
and the MobSpawnSettings.SpawnerData constructor silently rewrites any
miscellaneous-category entity type to pig.
Of the four, the client is given two and a half. Biome.NETWORK_CODEC sends
the climate settings, the syncable attributes and the effects, and
substitutes empty generation settings; the spawn-list attribute is not
syncable, so features, carvers and spawn lists never cross the wire. And when
a client asks for a biome in a chunk it does not have, it gets plains.
Questions players ask
Why do I always spawn near the origin? Because the chunk is chosen by a climate search. The search runs every time the world is loaded, since the structure state of a dimension whose settings name a spawn target asks for its result, but only a brand-new world takes a spawn from it, running the search a second time to do so (building the levels owns when). NoiseSpawnFinder.findSpawnPosition looks for the point whose
climate best matches the noise settings’ spawn target — a list of
SpawnTargetPoints, each a set of intervals on named density functions, for
the overworld five of the six climate functions with depth left out — in two spiral passes, the first out to 2,048 blocks and the second a finer one around its best, with the fitness
deliberately biased toward the origin so that a tie lands near 0,0.
NoiseBasedChunkGenerator.getOrigin keeps only that answer’s chunk, and
MinecraftServer does a terrain search from it: an eleven-by-eleven chunk
spiral of PlayerSpawnFinder.getSpawnPosInChunk, over a first guess taken from
ChunkGenerator.getSpawnHeight or, failing that, the WORLD_SURFACE
heightmap. A dimension whose settings name no spawn target skips the climate
half and starts that spiral at the origin chunk.
Why does /locate biome find biomes in chunks I have never visited?
Because it asks the generator, not the world. BiomeSource.findClosestBiome3d
spirals through a resolver from BiomeSource.createCachingResolver and never
reads a palette — so it finds biomes in ungenerated chunks and will never
find one placed by /fillbiome. It pre-filters against
BiomeSource.possibleBiomes, so asking for an impossible biome fails
instantly rather than after a spiral out to six thousand four hundred blocks.
For a 1.21-era reader. Biome.getMobSettings is gone: the spawn lists are the attribute
EnvironmentAttributes.NATURAL_MOB_SPAWNS.BiomeSourceis not itself aBiomeResolver; it makes one. RandomState.sampler and NoiseChunk.cachedClimateSampler are gone;RandomState.createClimateSamplerdoes their work. Climate.findSpawnPosition isNoiseSpawnFinder.findSpawnPosition.
Where to look
BiomeResolver.getNoiseBiome is the one method the whole search hangs off, and
BiomeSource.createResolver and BiomeSource.createResolverForChunk are where a
source makes one; read them with MultiNoiseBiomeSource beside them, and
TheEndBiomeSource after, for the source that does no search at all. Then
Climate whole — it is one file holding Climate.Sampler, Climate.quantizeCoord,
Climate.ParameterList.findValue and the Climate.RTree — and
Climate.PARAMETER_COUNT is the line that explains the seventh dimension.
OverworldBiomeBuilder.addBiomes is vanilla’s table, in Java, and
OverworldBiomeBuilder.addUndergroundBiomes is where the cave biomes turn out
not to be a separate system. For the two reads, BiomeManager.getBiome against
BiomeManager.getNoiseBiomeAtPosition, with BiomeManager.getFiddledDistance
as the jitter itself. Finish at Biome, which is short, and at
LevelChunkSection.fillBiomesFromNoise for where the answer is stored. Two
doors the page does not open: BiomeColors, for how the five effects fields
reach a rendered block, and Climate.RTree.search, for the walk the page only
describes.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Terrain
Verified against Minecraft 26.3 · Part XII · One chunk’s rock: seven hundred and sixty-eight cells filled from their corners, a water table decided before the caves are cut, and the cave that fills with water because of it.
You dig into a cave and it is flooded. The water is not a fluid that flowed
in and settled; nothing flowed anywhere. Before the cave existed, while the
chunk was still solid stone, something decided that a point at that depth in
that column belongs to water rather than to air — and when the hole the carver marked out came to be filled, the aquifer gave the same answer. A carver does not choose the block it carves. It chooses
the shape; the Aquifer chooses the material, and it chose it for the
stone as well.
This page is one chunk status, ChunkStatus.TERRAIN, and the three steps
inside it that turn a scalar field into blocks — the noise fill, the surface
pass and the carvers — which share one workspace for as long as the status’s
one task runs.
The conveyor that runs the statuses, the dependency pyramid and the
threading are the chunk generation
pipeline in Part IV;
this is the
cargo. The scalar field itself is density
functions, and the labels that
steer the surface pass are biomes.
The cast
| class | what it decides | when |
|---|---|---|
ChunkGenerator | the API the status calls — ChunkGenerator.buildTerrain, one call for all three steps. ChunkGenerators.bootstrap registers exactly three implementations | worldgen executor |
NoiseGeneratorSettings | the per-dimension recipe: the NoiseSettings floor and height, the default block and fluid, the NoiseRouter, the material rule, the sea level, and the aquifer’s own functions where there is one | data, loaded with the world |
NoiseChunk | the per-chunk workspace — the chunk’s DensityVolume, the samplers that carry its caches, the Aquifer | built when the terrain task starts, closed when it ends; a height query builds a one-column one of its own |
InterpolatedFunction | the cell: its input sampled at the corners, every block between them interpolated | whenever the graph is sampled |
Aquifer | what liquid, if any, belongs at a point — and therefore what a carved hole is filled with | fill and carving |
OreVeinRule | copper or iron, from the sign of one function | the surface step: in the overworld’s tree, after the bedrock floor and before any surface rule |
MaterialSystem | the column re-skin: grass over dirt over stone, sand, the badlands bands, the ore veins | the surface step and the carvers’ re-skin, one instance per level |
WorldCarver | the shape of caves and canyons, and nothing about their contents | the carver step |
Everything here starts on the worldgen executor, and for this generator the
whole status fans out from it: NoiseBasedChunkGenerator.buildTerrain hands
the fill, the surface pass and the carvers to the worker pool as one task
(which steps fork, and why the parallelism is smaller than the thread
names).
What is worth carrying from that into this page is one object.
RandomState — the per-level seed root — is built once in ChunkMap and
shared by every generating chunk, and it owns the MaterialSystem, which is
therefore per level, not per chunk.
Three steps, and what each hands on
flowchart LR
BIO["BIOMES"]
subgraph TER["TERRAIN, one task"]
FIL["fill"] -- "rock and water" --> SUR["surface"]
SUR -- "a skin, the veins" --> CAR["carvers"]
end
FEA["FEATURES, a later page"]
BIO -- "biomes, no workspace" --> FIL
CAR -- "holes, workspace closed" --> FEA
The terrain status is one task: the three steps inside it share one NoiseChunk, built when the task starts and closed when it ends, and the last box belongs to a later page.
The frame is the point. The three steps are one job.
ChunkStatusTasks.buildTerrain builds a Blender for the chunk’s
neighbourhood and hands it, with the region, to
NoiseBasedChunkGenerator.buildTerrain, which runs the fill, the surface pass
and the carvers back to back as one task. The workspace is born at the start
of that task, when NoiseBasedChunkGenerator.createNoiseChunk gives it the
chunk’s DensityVolume, samplers that carry the chunk’s caches, and an
Aquifer, and it dies at the end, when NoiseChunk.close hands its density
buffers back to RandomState. The biome step before it builds none of this:
it samples the climate through a caching sampler of its own
(biomes). Two passengers
ride in on the workspace, and both are things this page’s terrain is bent by
rather than things it decides.
The beardifier is one of them: a beardifier node in the final density’s
own data, which reads zero unless the sampling context holds a Beardifier —
and NoiseBasedChunkGenerator.createNoiseChunk puts one there for this chunk,
built by Beardifier.forStructuresInChunk
(wrap: once per chunk). Because it
is built with the workspace, a structure has bent the density field before the
field is ever sampled — the flat shelf under a village is a number added to a
scalar field, not a block edit made afterwards. What that term’s shape is —
Beardifier.Rigid boxes, junctions at half weight, and the five
TerrainAdjustment modes — belongs to structure placement.
A structure asking how high the ground is gets the answer from before it bent
anything: NoiseBasedChunkGenerator.iterateNoiseColumn, behind
ChunkGenerator.getBaseHeight and ChunkGenerator.getBaseColumn, builds a
throwaway NoiseChunk one block wide with an empty Blender and no
Beardifier at all, samples a column, and closes it.
The blender is the other, and it is why an empty one appears in that
sentence. Blender reads measurements harvested from chunks an older version
generated and enters the density graph as three nodes. The one that reaches the
graph is the one ChunkStatusTasks.buildTerrain builds with Blender.of for
this task, which NoiseChunk puts in the sampling context beside the
beardifier; the one the biome step builds bends only the biome resolver
(blending at the old-chunk border).
BelowZeroRetrogen, the world-deepening path, rides the same hooks: it wraps
the biome resolver, patches bedrock after the terrain step, and gates the spawn step (blending’s other passenger).
That one workspace then serves all three steps, so the carving step reuses what the aquifer has already worked out: the water tables it computes on its grid, and the surface levels it caches, are still there when the carved holes are filled — and since each is fixed by the seed, the position and, beside an old chunk, the blender, they would come out the same if they were not. The surface pass samples the preliminary surface and the ore veins through the same samplers and their caches (the one cache, and what a single point may use), and nothing is pinned to a thread: the three steps run back to back inside one task on one worker, so program order is all that serialises them.
Filling the noise: six loops, one number at the bottom
The fill asks for the whole chunk in one call.
NoiseBasedChunkGenerator.doFill has the workspace’s samplers sample the
router’s final density over the chunk’s DensityVolume — sixteen by 384 by
sixteen blocks in the overworld — and gets back one buffer holding a number
for every block. The cells are inside that call. NoiseSettings holds only a
floor and a height; the cell size belongs to the interpolated node in the
final density, and the overworld’s asks for four blocks across and eight up.
So the unit of overworld terrain is a cell four blocks wide, four deep and
eight tall, and a chunk is four by four by forty-eight of them.
768 cells make one overworld chunk, each holding 128 blocks (InterpolatedFunction).
An interpolated node samples its input at cell corners only. Everything inside a cell is three linear interpolations away from the eight corners around it, and the walk that does this is six loops deep:
flowchart TD
subgraph CZ["4 cell rows, in Z"]
subgraph CX["4 cell columns, in X"]
subgraph CY["48 cells, upward"]
subgraph BZ["4 block rows, in Z"]
subgraph BX["4 blocks in X"]
BY["8 blocks upward, one number written at each"]
end
end
end
end
end
Six loops, and none of them samples the graph: every corner was sampled in one call before the first, and everything nested inside is interpolation.
| loop | runs | what it does |
|---|---|---|
| cell row, in Z | 4 | nothing but advance |
| cell column, in X | 4 | reads the four corners at the column’s foot |
| cell, Y upward | 48 | reads the four corners above, which become the next cell’s floor |
| block row, in Z | 4 | four interpolations in Z |
| block, in X | 4 | two more in X, and the step from block to block in Y |
| block, Y upward | 8 | one addition, one number written |
Read the nesting as the cost model. Every corner is sampled once, in the one volume call made before the first loop, and nothing below it evaluates the graph again: each cell reads four new corners and inherits four, and every block inside it is arithmetic on eight numbers. The counts go up by one in each direction when you move from cells to their corners: four by four by forty-eight cells have five by five by forty-nine corners between them:
1,225 corner samples per chunk for each four-by-eight interpolated term the fill reads (InterpolatedFunction, one volume of five by forty-nine by five).
Six interpolated terms sit in the overworld router, resolved through every reference: one round the bulk of the final density, four inside the
noodle-cave graph, and one in chunk_surface_level, whose cell is a whole
chunk wide and one block tall. The fill reads the first five; the sixth is the
surface pass’s, and the ore veins bring three more of their own. Neither the
veins’ gap nor the aquifer’s barrier is interpolated: the vein rule and the
Aquifer sample each block by block, and only where they need it. Everything
else is sampled at the resolution of the volume it is asked for, less any axis
it does not depend on — a term that ignores Y is sampled once per column (the one cache, and what a single point may use).
Then NoiseBasedChunkGenerator.doFill walks the finished buffer block by
block — Z, then X, then Y downward — and hands each number to the aquifer.
The two worldgen heightmaps are updated as those blocks are written, so walking down, a column’s height for each map is settled by the first block that counts for it.
The write at the bottom of that walk skips air entirely — a chunk starts empty,
so only non-air is ever written — and updates
Heightmap.Types.OCEAN_FLOOR_WG and Heightmap.Types.WORLD_SURFACE_WG by
hand rather than through the chunk’s ordinary write path. It can, because it
holds every section across its noise range for the whole fill
(what placing a block actually does).
Those two stop being maintained once this status completes, and not because
anything stops writing them: which maps are live is a property of the
status, not of the step — the two _WG maps up to ChunkStatus.BIOMES,
the status the chunk still holds while all three steps run, and the four final
ones from ChunkStatus.TERRAIN on, primed by Heightmap.primeHeightmaps as
the terrain task ends
(the six heightmaps). A chunk
saved mid-generation really does persist the pair the fill was keeping.
The aquifer: what the number becomes
NoiseBasedChunkGenerator.doFill does not read the density and compare it
to zero. It hands the number to Aquifer.computeSubstance and writes what
comes back, and the aquifer is the only thing the number meets.
The aquifer. Aquifer.computeSubstance receives the final density and
decides, from four noises of its own — a barrier it samples block by block,
and floodedness, spread and lava, which it samples on coarse grids — whether
this point is stone, air, or a fluid. None of them is read from the router:
the noise settings carry an Aquifer.Config of six functions, those four, an exclusion that forbids a local water table in any aquifer cell where it is positive, and surface_level — which the overworld points at its preliminary_surface_level function — which it samples once per four-by-four column to
know roughly where the ground is before any ground exists.
Aquifer.FluidPicker is the global fallback underneath its local water tables — the sea, and the lava — and Aquifer.FluidStatus is the record of any one table, local or global. Aquifer.NoiseBasedAquifer is the real
implementation; a dimension with aquifers switched off gets a trivial one.
The ore veins are not decided here at all. OreVeinRule is a material
rule, and the overworld’s rule tree tries its two veins, copper then iron, after its bedrock floor and ahead of every surface rule, so the surface pass writes them into the stone
the fill left. That is why copper and iron veins are terrain rather than
decoration: they exist before the carvers, and no feature places them. Every
other ore in the game is an AbstractOreFeature placed at
ChunkStatus.FEATURES
(features and placement).
Which of the two veins you get is the sign of one function,
ore_vein/toggle — copper’s density reads it as it is and iron’s reads it
negated — and there is no separate “which ore” noise.
If the aquifer returns nothing, the block becomes the settings’ default block.
The surface pass, and the two places it breaks its own rule
MaterialSystem.buildSurface compiles the dimension’s
NoiseGeneratorSettings.materialRule tree once for the whole chunk — one rule
tree per dimension, which then branches on biome inside itself, then walks each of the 256 columns downward
from the worldgen surface heightmap, tracking depth below stone and water
height in a MaterialRuleContext that carries its own caches. Every block
that is neither air nor fluid is offered to the tree, and the first rule that
answers is written: fluid is measured, never offered, and the overworld’s tree tries the ore veins, after its bedrock floor, before any surface rule, which is what makes aquifer water
and ore veins immune to being turned into grass.
That rule tree is itself two registries of data-driven types, with files of
their own that the noise settings name by id — MaterialRule for what to
write and MaterialCondition for when, each dispatched on a type id like any
other (the data-driven type
pattern) — so a pack composes a surface out of the shipped condition types and cannot write a new kind of condition. The biome the tree branches on is the jittered read,
BiomeManager.getBiome, not the palette’s exact one
(the two borders), which is why a surface rule
can change block for block along the same ragged line the grass colour does.
Almost none of this runs in a superflat world. FlatLevelSource lays its
layers at the terrain status and DebugLevelSource writes nothing there —
neither has a surface pass or carvers, both make spawning a no-op, and
DebugLevelSource writes its state grid at the decoration step instead —
so of the three ChunkGenerators.bootstrap registers, two skip
most of this page. Debug flags can switch off much more: SharedConstants carries flags, set from JVM system properties in any build, that disable the surface pass, the carvers,
the ore veins and every aquifer’s water and lava outright.
Two things sit outside the rule tree entirely, and neither asks it.
MaterialSystem.erodedBadlandsExtension runs before the column walk
and fills air with the default block to raise the terracotta pillars.
MaterialSystem.frozenOceanExtension runs after it and writes snow and
packed ice over air and over water. Both are selected by biome
rather than by rule, and MaterialSystem holds the noises they need along with the one that offsets the badlands bands and the two behind the surface depth the conditions read; a noise-threshold condition asks RandomState for its own.
Carving, and who chooses the block
NoiseBasedChunkGenerator.generateCarvers does not carve the chunk it was
given from the chunk it was given. It loops over a 17×17 neighbourhood of
source chunks, asks each carver of that source chunk’s biome whether a cave
or a canyon starts there, and has whatever does mark its shape into the
centre chunk. That reach costs the dependency pyramid nothing: the neighbours
are read only as memo holders for ChunkAccess.carverBiome, and the biome
itself is recomputed from the biome source. The reach is already paid for,
because every generation step from ChunkStatus.STRUCTURE_REFERENCES to
ChunkStatus.FEATURES asks for structure starts eight chunks out anyway
(the pyramid, drawn).
Two carver types are registered by WorldCarverTypes.bootstrap — cave
and canyon, which are CaveWorldCarver and CanyonWorldCarver — and four
carvers ship, because the cave type is configured three times: once for
the surface caves, once for a deeper set, and once for the Nether. Each is a
record carrying its own parameters, and its codec is the data-pack half: no
configuration class stands between carver and file. None of them touches a
block. A carver is handed a WorldGenerationContext for its heights and a
CarverOutput to mark, and what it marks lands in a CarvingMask, a bit set
that lives for one terrain task and is never saved.
And then the hook. When every source chunk has had its say,
NoiseBasedChunkGenerator.applyCarvingMask walks the mask’s carved runs, each top down, and asks Aquifer.computeSubstance with a density of
zero what belongs at each marked block. Aquifer.FluidStatus.at
answers plain air above the local water table and the fluid below it — never
null; the null is Aquifer.computeSubstance’s own, and it means do not carve
here at all. So the water in a flooded cave
was decided by the same object that decided the water in the stone around
it, and a dry cave is air written one block at a time because the aquifer
said so. What kind of block a cave may not eat through is decided by one tag, uncarvable
(BlockTags.UNCARVABLE), which ships holding bedrock alone, so a cave cuts
through the ore veins and the surface skin alike. If a grass or mycelium
block was passed on the way down, the dirt below is re-skinned through
MaterialSystem.topMaterial.
The Nether follows the same rule. Its nether_cave is the cave type with other numbers, and its holes are filled by the same question, put to the trivial aquifer a dimension with aquifers switched off gets — which answers from the global fluid picker alone: lava, the Nether’s default fluid, below its sea level of 32, and air above.
The seeding is one thing a data pack cannot reach here.
NoiseBasedChunkGenerator.generateCarvers hardcodes a LegacyRandomSource
whatever the settings say, so switching a dimension to the modern random family
does not move a single cave. That setting,
NoiseGeneratorSettings.useLegacyRandomSource, does reach past the level’s root
random in one other way: it re-seeds BlendedNoise.
Questions players ask
Why is there always lava at the same depth, in every world? Because the
two levels are anchored to different things. The sea level is a field of the
noise settings and moves with them; the lava floor is not in the settings at all.
NoiseBasedChunkGenerator.createFluidPicker builds the global fluid picker
every aquifer falls back to, and it answers lava below Y −54 — or below the
sea, where the sea is lower still — and the sea above. So the lava floor under
every overworld cave, carved or not, is one fixed height rather than a depth
below the sea, and a data pack moves it only through the sea: by sinking the sea beneath it, or by making the settings’ default fluid lava.
Why is the water in a cave already settled when I break into it? Because it was decided before the cave was, and placed as still water. Aquifer flags only the fluid it places near the border between two aquifer cells, and there only where it could flow — where the cells’ tables differ, in height or fluid or because one is dry, or where water sits directly on the lava floor — and the fill and the carving step record those positions for post-processing, so those few flow for real when the chunk starts ticking (what the chunk goes on holding).
“Nothing flowed in” is a true statement about worldgen and not about the
chunk’s first tick as part of a live world.
For a 1.21-era reader. NOISE, SURFACE and CARVERS are one status,
ChunkStatus.TERRAIN, and the cell loopNoiseChunkran isInterpolatedFunction’s. SurfaceSystem isMaterialSystem, and SurfaceRules areMaterialRuleandMaterialCondition, with the ore veins among them asOreVeinRule; OreVeinifier is gone. ConfiguredWorldCarver and the carver configurations are gone — a carver is a record that carries its parameters — and NetherWorldCarver is gone, the Nether’s caves being the cave carver with other numbers.
Where to look
NoiseBasedChunkGenerator is the page in one class:
NoiseBasedChunkGenerator.buildTerrain is the status, and
NoiseBasedChunkGenerator.doFill, NoiseBasedChunkGenerator.buildSurface
and NoiseBasedChunkGenerator.generateCarvers are its three steps; reading
them in that order is the trace. Under the first, NoiseChunk is the
workspace and InterpolatedFunction the six loops, and
Aquifer.computeSubstance is the decision at the bottom of the write walk and
repays being read twice, because the carving step calls it too.
MaterialSystem.buildSurface is the column walk, and MaterialRule beside
MaterialCondition is the data-pack half of it, with OreVeinRule for the
veins. Then NoiseBasedChunkGenerator.applyCarvingMask, which is the sentence
this page is built around, with CaveWorldCarver for the shape.
NoiseGeneratorSettings is worth opening once to see how much of all this is
data. Two doors the page does not open: MaterialRuleContext, for what a rule
may read, and OreVeinRule.compile, for a vein in full.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Blending at the old-chunk border
Verified against Minecraft 26.3 · Part XII · a chunk generated beside one an older version left behind: one measurement of the old ground, and the five places generation is bent around it.
In a world old enough to have been generated by a previous version, the debug screen’s chunk-generation entry reads Blending: Old on one side of a line and stops reading it on the other. For roughly a hundred blocks on the new side of that line the game is generating chunks that are not free to be whatever the seed says — and directly against the seam they are hardly generated at all. The three functions that shape overworld terrain, offset, factor and jaggedness, are each swapped for a fixed target: a ground height measured from the old chunk’s own blocks, the constant ten, and zero. At the seam the overworld’s terrain splines have no say.
This is the deliberate exception the part’s premise names. Everywhere else in world generation, a chunk is built to be a function of the world’s settings and its data packs. Here it is a function of those and of the blocks in the old chunks around it — which the game remembers only in part and has to go and measure, one column at a time.
The flag is a nullable field, and looking for it costs a disk read
A chunk is old if ChunkAccess.blendingData is non-null. That is the whole
test: ChunkAccess.isOldNoiseGeneration returns exactly whether that field is
set, and the field is final — it arrives through the constructor from
SerializableChunkData, the record that reads a chunk’s NBT
(chunk storage), which pulls a
blending_data compound out of it and nothing sets afterwards. Which saves
carry that key is util/datafix’s business, which
this book skips; by the time world
generation sees a chunk the key is either there or it is not.
The awkward part is cost. A chunk being generated could ask its neighbours whether they are old — the dependency window guarantees them — but that is 193 chunks to reach for an answer that, in a world this version created, is always no. So Blender.of —
the factory the BIOMES and TERRAIN steps call — starts by asking the save
file. WorldGenRegion.isOldChunkAround hands the question to the level’s
ChunkMap, which inherits SimpleRegionStorage.isOldChunkAround and lands on
IOWorker.isOldChunkAround. That walks the region files covering a square of
radius seven, and for each region it needs a BitSet with one bit per chunk,
built by scanning all 1,024 chunks in the region — a region file is
thirty-two by thirty-two
(inside a region file) — for
two NBT fields and
nothing else: DataVersion and blending_data. The save check counts a chunk if its stored DataVersion is below 4997 or if it already carries a blending_data compound — a wider net than the flag, so a yes can still end in the empty blender. The scan runs on the worker pool, the caller joins it, and
the bitset is kept in a 1,024-entry region cache.
Every chunk generated in every world pays for that lookup twice,
whatever generator the dimension uses:
ChunkStatusTasks.generateBiomes and ChunkStatusTasks.buildTerrain both
evaluate Blender.of eagerly, before knowing whether the answer can possibly
be yes (which steps may
write),
and nothing else in the game calls it. The noise generator adds none: its
carving pass asks the TERRAIN run’s blender for its filter. When nothing is old the
bitsets come back empty and Blender.of hands back the shared empty blender,
an anonymous subclass over two empty maps that overrides the three answering methods with identities: alpha one, offset zero, density unchanged,
resolver returned as given. The lookup is cheap after the first time, because
the per-region bitset is memoised behind
IOWorker.getOrCreateOldDataForRegion and so is scanned once per region rather
than once per chunk; a debug flag set from JVM system properties, SharedConstants.DEBUG_DISABLE_BLENDING, can drop it entirely, which short-circuits
Blender.of, the carving-mask filter and the border ticks alike.
The cast
| class | what it decides | when |
|---|---|---|
Blender | the three answers — the height alpha and offset, the blended density, and the biome override — and the carving filter it builds beside them. The border ticks are a static method on the same class and never ask an instance anything | built per step on the worldgen executor, asked from the worker pool; the border ticks run inline at ChunkStatus.FEATURES |
BlendingData | one old chunk’s measurements: a ring of sixteen columns holding a height, a density profile and a biome column each | measured once per loaded chunk object |
IOWorker | whether any chunk within seven is old, from a per-region BitSet scanned out of the region files | on the worker pool, joined by the caller |
NoiseChunk | where the answers enter the density graph: the blender and its two samplers, set in the sampling context | ChunkStatus.TERRAIN, one per run |
NoiseRouterData | which router functions the shipped data makes blendable — three overworld splines and one post-process wrapper | build time, emitted as the JSON that ships |
CarvingMask | the positions one run’s carvers dig, and the CarvingMask.Filter that, beside an old chunk, lets only those near it become blocks | ChunkStatus.TERRAIN |
BelowZeroRetrogen | the separate world-deepening path that rides the same hooks | ChunkStatus.TERRAIN and ChunkStatus.BIOMES |
SerializableChunkData | the blending_data key: which chunks are flagged, and which measurements survive a save | chunk load and save |
One measurement, five consumers
BlendingData is gathered once and then read by five unrelated pieces of
machinery at three chunk statuses — two inside the density graph, three nowhere near it, and two of the five reading only the old area the chunk was saved with, not its measured columns.
flowchart TD
OLD["an old chunk, tagged blending_data"] --> BD["BlendingData: sixteen measured columns"]
BD --> B["Blender: two maps and a carving filter"]
B --> R1["BIOMES: the nearest old biome, or none"]
B --> R2["TERRAIN: blend_alpha, blend_offset"]
B --> R3["TERRAIN: blend_density inside the final density"]
B --> R4["TERRAIN: carving only near old chunks"]
BD --->|"its old area, read directly"| R5["FEATURES: border ticks, old chunk only"]
One old chunk’s BlendingData fans out five ways: three consumers go through the Blender and its two maps of measured columns, the carving pass through the filter Blender.of builds from nine chunks’ old areas, and the border ticks read the old chunk’s own old area with no blender at all.
Two maps, not one. Blender.of sweeps the square from seven chunks west to
seven chunks east and clips it to a circle — the test is that the squared
offsets sum to no more than sixty-four — which leaves 193 positions. Every
one that yields data goes into the height-and-biome map; only the inner three
by three also goes into the density map. That asymmetry follows the two reaches — the height blend looks twenty-seven quart cells out and the density blend two — and the reaches, not the maps, are why the terrain seam is a hundred blocks wide and the cave seam is a handful.
A neighbour yields data only if it passes two tests in
BlendingData.getOrUpdateBlendingData: it carries a BlendingData, and
ChunkAccess.getHighestGeneratedStatus is not before ChunkStatus.BIOMES.
The first turns away every chunk this version generated, which never carries one. The second turns away a flagged chunk whose status, with any deepening target folded in, is before BIOMES.
Seven chunks is the radius the height blend reaches, which Blender derives from the twenty-seven-cell height range. The cell here is the quart —
the four-by-four-by-four volume a biome is stored per
(biomes) — and the
arithmetic is four quart cells per section across seven sections, less one, plus
three, converted back to chunks. It sits
inside the radius-eight
dependency window the BIOMES and TERRAIN steps both declare, so none of the 193 reads can
trip the crash a read past that window would be
(a read too far crashes, a read too wide only
warns).
Sixteen columns, read out of blocks
BlendingData does not store a copy of the old terrain. It stores sixteen
columns in a ring round the chunk’s edge — a column here being one horizontal
position sampled through the chunk’s old area, the vertical band between the minimum and maximum sections saved with it — measured out of that same old chunk’s own blocks the first time anyone asks: BlendingData.getOrUpdateBlendingData fetches one chunk and hands it to its
own BlendingData.calculateData. Seven of them are inside
indices — the corner and three more along the north edge, three along the
west — at block coordinates 0, 4, 8 and 12. The other nine are outside
indices, sampled at block coordinate 15 along the east and south edges, which
in cell arithmetic belong to the next chunk’s first cell. Sixteen slots is
what BlendingData.Packed’s codec validates the saved height array against,
and it is exactly the array length the class computes from a chunk being four
quarts wide.
Which of the sixteen get filled depends on which way the chunk faces new
ground. BlendingData.sideByGenerationAge asks each of the eight CompositeDirection.Direction8
neighbours whether it is old, and BlendingData.calculateData fills only the
columns on the sides that are not — the ones facing chunks the game is
about to generate. There is one call site in the whole game and it passes
false, so “sides by generation age” only ever means “sides facing new
chunks”. The method also guards on BlendingData.hasCalculatedData, so a
chunk object measures itself once and never again: whichever sides were new at
that moment are the sides it will carry until it is unloaded.
Each filled column gets three things. A height:
BlendingData.getHeightAtXZ starts at the WORLD_SURFACE_WG heightmap (the six heightmaps) if the chunk has one primed, capped at the top of the old area, and at the top of the old area if not, then walks
straight down looking for one of eleven block types — podzol, gravel, grass,
stone, coarse dirt, sand, red sand, mycelium, a snow block, terracotta or
dirt — and
returns the first Y at which it finds one, or the bottom of the old area if it
never does. A density profile: for each eight-block-tall noise cell,
BlendingData reads fifteen consecutive blocks downward, scoring each plus or
minus one for whether it is ground, and divides by fifteen — ground meaning
not air, not a leaf, not a log, not a mushroom block and with a non-empty
collision shape, so a cave counts as air and a tree does not count as terrain.
One more pass rewrites the two cells straddling the measured height so the
surface lands where the height said it did. And a biome column: one
Biome holder per four-block layer of the old area, read straight out of that
chunk’s own biome container.
Of those three, only the heights are saved. BlendingData.pack writes the
minimum section, the maximum section and the sixteen floats, and omits the
heights entirely if none of them was ever measured; the density array is
declared transient and the biome list is not in the codec at all. Unload the
region and come back and the game re-reads the old chunk’s blocks to rebuild
both.
Following one chunk through
sequenceDiagram
participant CST as ChunkStatusTasks
participant Blender as Blender
participant CM as ChunkMap
participant BD as BlendingData
participant NBC as NoiseBased<br/>ChunkGenerator
participant NC as NoiseChunk
participant BDFS as BlendDensityFunction.<br/>Sampler
rect rgba(0, 0, 0, 0.04)
Note over CST,BDFS: ChunkStatus.BIOMES, the blender built on the worldgen executor, the fill forked to the worker pool
CST->>Blender: of, a blender for this chunk
Blender->>CM: isOldChunkAround, radius 7, through the region
CM-->>Blender: yes, from the region files' bitsets
loop 193 positions, clipped to a circle
Blender->>BD: getOrUpdateBlendingData
BD->>BD: measure the sides facing new chunks, once
end
Blender-->>CST: a live blender, two maps of BlendingData
CST->>NBC: createBiomes, with that blender
NBC->>Blender: getBiomeResolver, wrapped again if the chunk is being deepened
end
rect rgba(0, 0, 0, 0.04)
Note over CST,BDFS: ChunkStatus.TERRAIN, a second blender built the same way
CST->>Blender: of, the same lookup and sweep again
Blender-->>CST: a second blender, the one the terrain gets
CST->>NBC: buildTerrain, with the second blender
NBC->>NC: the constructor, one NoiseChunk per run
NC->>Blender: blendOffsetAndFactor, 25 columns, into the sampling context
loop every point the blend_density node samples
BDFS->>Blender: blendDensity, when the context holds a live blender
Blender-->>BDFS: the old density outright, or a lerp toward the noise
end
NBC->>Blender: getCarvingFilter, before carved blocks are written
end
The blender that bends a chunk’s terrain is the one built at ChunkStatus.TERRAIN: the NoiseChunk is made inside that run and takes its answers into the sampling context, while the one built at ChunkStatus.BIOMES answers biomes and nothing else.
Two blenders are built, then, the same way, and each is asked different
questions. The biome step builds no NoiseChunk — it samples climate through
a caching sampler of its own — so its blender is asked for the biome override
and nothing else. The NoiseChunk is made inside
NoiseBasedChunkGenerator.buildTerrain, one per run and closed when the run
ends, so the blender that reaches the density graph, and the carving filter
with it, is the one built at TERRAIN.
The twenty-five columns are the other thing worth noticing, because they are
where this page’s measurements physically enter the density graph. Before it
binds a single sampler, the NoiseChunk constructor hands its blender the
chunk’s volume, and Blender.blendOffsetAndFactor works through the
five-by-five grid of quart columns that covers it and returns a
Blender.OutputBuffer of twenty-five alphas and twenty-five offsets. The
constructor sets the blender and the buffer’s two samplers in the sampling
context, under Blender.CONTEXT_KEY, Blender.ALPHA_KEY and
Blender.OFFSET_KEY, which is where the two blend leaves and the
blend_density node look for them. With no old chunk near it sets none of
the three, so the leaves fall back to the constants one and zero and
blend_density hands its input through unchanged
(wrap: once per chunk).
What the blender actually answers
Three questions, three different shapes of answer, and only two of them are
blends. Three is fewer than the five consumers above because the last two are
not answered from the maps: the carving filter is built by Blender.of
straight from the BlendingData of the chunk and its eight neighbours, the
border ticks read the old chunk’s own, and neither of the two maps is involved.
Height, as an alpha and an offset
For each column, Blender.blendOffsetAndFactor first checks whether the sample point sits exactly on a measured column; if it does,
it returns alpha zero and the old height, put through
Blender.heightToOffset — a rational function, not a polynomial. If not,
it walks every measured height in the height map, keeps those within
twenty-seven quart cells — a hundred and eight blocks — and averages them
weighted by the inverse fourth power of distance, with alpha the smoothstep of
the nearest distance over twenty-eight. Nothing in range means alpha one and
offset zero: the identity.
Alpha is then used as a mixing weight in NoiseRouterData.splineWithBlending,
which interpolates from a fixed target at alpha zero to
the real spline at alpha one, and this is where the page’s opening claim comes
from. Exactly three router functions are built that way — offset, factor
and jaggedness, for each of the three overworld variants — and their targets
are, respectively, SimpleDensityFunction.BLEND_OFFSET,
NoiseRouterData.BLENDING_FACTOR (the constant ten) and
NoiseRouterData.BLENDING_JAGGEDNESS (zero). Against the seam alpha is zero,
so overworld terrain is not shaped by its splines at all: the ground height is
whatever BlendingData measured, the factor is ten and the jaggedness is
nothing. The shipped data pack agrees — blend_alpha appears in nine density
function files and blend_offset in three, all of them under the three
overworld directories.
Because offset and factor are also the two inputs to
NoiseRouterData.preliminarySurfaceLevel, the surface_level the aquifer is
configured with
(the aquifer), the
water table follows the old ground. Nobody
wrote a rule for that: it falls out of the aquifer sampling a blended
function.
Density, as a lerp with a very short reach
Blender.blendDensity is called per sample by BlendDensityFunction.Sampler,
the compiled form of BlendDensityFunction — the wrapper blend_density
decodes to — whenever the sampling context holds a live blender. It measures distance in noise cells with the Y difference doubled — noise cells are twice as tall as they are wide — keeps
neighbours within two, and mixes toward the noise with an alpha of the closest
distance over three. An exact hit returns the old density with no mixing at
all. This one wrapper is in every dimension’s router: blend_density is
written into four of the seven shipped noise settings and into the three
overworld final_density files the other three name, while the alpha and
offset nodes are overworld-only.
Biome, as a replacement
Blender.getBiomeResolver wraps the biome source’s resolver
in another BiomeResolver — the functional interface the chunk’s biome fill takes
(the trace: a chunk’s biomes) — that
asks Blender.blendBiome first and only falls through when
it declines. And it is not a blend: it finds the nearest measured biome within
twenty-seven cells, adds twelve cells’ worth of a fixed shift noise to that
distance, divides by twenty-eight, and returns the old biome if the result is
below one half and nothing at all if it is above. So the biome boundary is a
hard line at roughly half the terrain blending distance, roughened by noise —
one biome or the other, never a mixture, which is the only answer a palette of
biome holders can represent.
Between them those three shapes are why the blend can still leave a visible edge, in two different ways. A column whose old surface is none of the eleven blocks the height scan recognises reports the bottom of the old area instead, and that bogus height is averaged in with the rest. And because the biome is a threshold rather than a ramp, the material rules can change block for block along a line while the terrain under them is still sloping.
The two consumers that never touch the noise router
Carving is held to the seam. At TERRAIN the blender carries one thing
besides its two maps: Blender.of ends with
Blender.createAroundOldChunksCarvingMaskFilter, which collects the
BlendingData of all eight CompositeDirection.Direction8 neighbours plus the
chunk’s own, turns each into a Blender.DistanceGetter measuring distance to a
box eight blocks either side of the chunk centre in X and Z — a whole chunk
wide — and as tall as that chunk’s old area, and puts the minimum of them
behind a CarvingMask.Filter, or builds none if none of the nine is old. A
position within four blocks of any such box — after each axis is displaced by
the same shift noise, scaled by four — passes it. The carvers never see it:
WorldCarver.carveEllipsoid records what they dig in the run’s CarvingMask
(carving, and who chooses the block),
and NoiseBasedChunkGenerator.applyCarvingMask, turning those bits into
blocks, is handed the filter Blender.getCarvingFilter returns and writes
only the positions it passes. So beside an old chunk the carvers’ work
survives only within about four blocks of it, and the rest is dropped. Neither
the filter nor the mask is ever saved.
Leaves and water at the seam are marked for a second look. At FEATURES,
after decoration, ChunkStatusTasks.generateFeatures calls
Blender.generateBorderTicks — and this is the one hook that acts only on the old side. It returns immediately unless the chunk
being generated carries a BlendingData of its own, which a chunk generated
fresh today never does: it acts only on an old chunk that is still being
carried through the statuses, one saved before FEATURES or one being
deepened. Given such a chunk it sweeps four Y levels — one below and one at
the bottom of the old area, one at and one above the top — across all 256
columns, and then, for each of the four horizontal neighbours that is not
old, walks the whole sixteen-wide face from the bottom of the old area up to that column’s MOTION_BLOCKING height, capped at the top of the old area. Every leaf block and every non-empty
fluid it passes goes to ChunkAccess.markPosForPostProcessing. Nothing is
changed: the positions are queued for the post-processing pass that runs when
the chunk becomes live
(what the chunk goes on holding),
which is what makes water at the seam flow and
orphaned leaves decay instead of hanging there. The heightmap read is safe whatever the chunk’s history: an old chunk loaded at TERRAIN had its four final maps primed as it was read from the save, one that ran TERRAIN here had them primed as ChunkStatusTasks.buildTerrain ended, and ChunkAccess.getHeight primes a missing one on demand besides.
The other passenger
BelowZeroRetrogen is not blending, but it rides the same hooks and is easy
to mistake for it. A chunk carrying one is being deepened, whether or not it is also blended: ChunkAccess.getHighestGeneratedStatus folds its
BelowZeroRetrogen.targetStatus in
(the four shapes a chunk takes),
ChunkStatusTasks.buildTerrain calls
BelowZeroRetrogen.replaceOldBedrock and then
BelowZeroRetrogen.applyBedrockMask if there are holes, and
BelowZeroRetrogen.getBiomeResolver wraps the blender’s resolver in one more
layer that keeps three cave biomes from the new generation and takes everything else from the biome the chunk already has at Y 0 in each column.
Where to look
Start at Blender.of and read down: the two maps, the two radii, the three
answering methods. Then BlendingData.calculateData and
BlendingData.getHeightAtXZ for what a measurement is, and
BlendingData.Packed for what survives a save. NoiseChunk’s constructor,
SimpleDensityFunction and BlendDensityFunction show where the answers enter the density graph,
NoiseRouterData which three functions were built to receive them, and
ChunkStatusTasks when each hook fires.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Features and placement
Verified against Minecraft 26.3 · Part XII · A chunk decorates: a stream of positions folded through filters, an order every chunk in the dimension already agreed on, and a data pack that can stop the world from opening.
The oak in the middle of a plains chunk was not placed by the plains biome. It was placed by a list that every biome in the dimension contributed to, sorted once per dimension into one order per decoration step, with an index per entry that the random seed for each feature is derived from. That is how the same seed grows the same forest, all but where two chunks’ trees meet (below) — and it is also why two biomes that list the same two features in opposite orders make the world refuse to open. The order is a topological sort of a graph, and a graph can have a cycle.
Decoration is everything the terrain step did not put there: trees, flowers,
ores, lakes, patches, springs. The system separates three things a modder
usually wants separately — what to build, where to try, and who wants
it — and this page is how those three meet at ChunkStatus.FEATURES. The what and the where are registries of dispatched types (the data-driven type pattern), and the who is the biome. The
biggest single feature, the tree, has its own page
(trees); the terrain that decoration
lands on is terrain.
The chain, before anything else
Everything below is one picture: a stream that starts as a single position and is flat-mapped through an ordered list of modifiers until whatever survives it gets built. A realistic chain for a tree runs like this.
flowchart TD
A["the chunk corner, at minimum Y"] -->|"1 position"| B["RarityFilter rolls"]
B -->|"1 or 0"| C["CountPlacement copies it"]
C -->|"N, all alike"| D["InSquarePlacement scatters"]
D -->|"N, spread out"| E["SurfaceWaterDepthFilter drops some"]
E -->|"N or fewer"| F["HeightmapPlacement sets Y"]
F -->|"on the surface"| G["BlockPredicateFilter: would a sapling survive?"]
G -->|"fewer"| H["BiomeFilter: does this biome list it?"]
H -->|"fewer"| I["Feature.place, once each"]
One position becomes many and then fewer: only the count multiplies, Y is not set until the fifth modifier, and the shipped plains trees run this chain without its rarity roll.
List order is the meaning — the same seven modifiers in another order place a different forest, or none — and the rest of the page is who assembles that list, who seeds it, and what each link in it is allowed to know.
The cast
| class | its job | notes |
|---|---|---|
Feature | the algorithm and its parameters in one object, with one working method: Feature.place, handed the level, the generator, a random source and an origin, and returning whether it succeeded | 58 types registered into BuiltInRegistries.FEATURE_TYPE; the instances are the data registry Registries.FEATURE, and an instance is the unit a sapling grows |
PlacedFeature | a feature plus an ordered list of modifiers | the unit a biome names — the one of the two that owns placement modifiers |
PlacementModifier | one function from a position to zero or more positions, handed to a consumer, over a PlacementContext | 18 registered types |
FeaturePlacer | the fold: walks a placed feature’s positions through its modifiers depth first, on one stack, and calls the feature at each position the last modifier emits | the driver builds one per chunk; only its biome-check entry records which placed feature a chain started from |
GenerationStep.Decoration | the eleven steps, in order, from raw generation to top-layer modification | a biome’s list is a list of lists, by ordinal |
FeatureSorter | flattens every possible biome’s per-step lists into one sorted list per step, with an index lookup | run once; ChunkGenerator.featuresPerStep is the memoised field that holds its answer |
WorldgenRandom | the seed, reseeded absolutely twice: once per chunk, then once per feature | on the worldgen executor |
One chunk’s decoration, from the corner outward
sequenceDiagram
participant ChunkG as ChunkGenerator
participant WR as WorldgenRandom
participant FeatP as FeaturePlacer
participant PMod as PlacementModifier
participant Feature as Feature
Note over ChunkG,Feature: ChunkStatus.FEATURES, on the worldgen executor
ChunkG->>ChunkG: applyBiomeDecoration, write radius 1
ChunkG->>ChunkG: read featuresPerStep, sorted once and memoised
ChunkG->>WR: setDecorationSeed, from the level seed and the corner
ChunkG->>ChunkG: the biomes of the 3x3 chunks, cut to the possible ones
loop per decoration step, its structures first
loop per placed feature those biomes list, in index order
ChunkG->>WR: setFeatureSeed, from the index and the step
ChunkG->>FeatP: placeWithBiomeCheck, from the chunk corner
loop until its stack is empty, depth first
FeatP->>PMod: modify, one position into zero or more
alt that was the last modifier in the list
FeatP->>Feature: place, at each position it emitted
else an earlier modifier
FeatP->>FeatP: push them, each for the next modifier
end
end
end
end
Two reseedings and a depth-first stack: every feature is reseeded from its own index before it runs, and each position the last modifier emits is one call into the feature’s algorithm.
The driver. ChunkGenerator.applyBiomeDecoration starts at the chunk’s
minimum corner, at the world’s minimum Y. There is no eight-block population
offset; the scatter comes later, from a modifier.
The seed. A WorldgenRandom is built over a genuinely random seed and
then reseeded absolutely, twice, before anything uses it.
WorldgenRandom.setDecorationSeed derives a per-chunk seed from the world
seed and the chunk corner, and each feature then gets
WorldgenRandom.setFeatureSeed from that seed, its index within the step and
the step number, which the seed multiplies by ten thousand to keep the steps
apart. Features in a step therefore do not share a random stream: every one
is reseeded absolutely before it runs, so an extra draw inside a feature
perturbs the rest of that feature and nothing after it.
Who wants what. The biome palettes of the surrounding 3×3 chunks are
unioned and intersected with the biome source’s possible biomes
(biomes). Every placed feature any of those biomes lists for this step (the per-step lists on its BiomeGenerationSettings) is collected by its index in
that step’s list, and the indices are sorted — that sort is the execution order, and
it is the same for every chunk in that dimension, because
ChunkGenerator.featuresPerStep is memoised per generator and built from
that generator’s possible biomes. The Nether’s order has nothing to do with
the Overworld’s. Within each step, structures at that step are placed before
its features (structure
placement) —
and they share the step loop without sharing the index space, so a structure
and a feature sitting at the same index in the same step draw the same
feature seed. They differ in reach as well: a structure piece gets an explicit writable box covering the centre chunk, where a feature gets only the
softer write-zone check below.
The order is a graph, and a graph can have a cycle
That sort is where a data pack can stop a world from opening, and it is the
only place in generation where one can. Every biome’s per-step list contributes
this before that edges to one graph, whose nodes are features at their steps, and
FeatureSorter.buildFeaturesPerStep topologically sorts it. Two biomes listing
the same two features in opposite orders form a cycle, and the sort throws
rather than returning an order — it will even re-run itself, dropping one source
at a time, to name a minimal offending set.
Where you find out depends on which side you are on. The client calls
ChunkGenerator.validate from WorldOpenFlows while opening the world, catches
the exception and offers safe mode. A dedicated server never calls
ChunkGenerator.validate at all, so there the cycle surfaces later, as a crash
report wrapped around the first chunk that tries to decorate.
The fold
FeaturePlacer.placeWithBiomeCheck starts that stream at the chunk corner, at
the world’s minimum Y, and flat-maps it through each modifier in turn, depth
first on one stack. Nothing about it is a filter chain in the usual sense: a
modifier may emit nothing, one position, or many.
Four of the modifiers in that chain are pure filters — RarityFilter at the
head, which turns one position into one or none on a roll;
SurfaceWaterDepthFilter; BlockPredicateFilter, the survival check; and
BiomeFilter — and they are what a PlacementFilter is: a modifier that
emits the position it was given, or nothing. Three other things about the
chain are load-bearing, and none of them is obvious from a data pack.
A repeating placement does not scatter. CountPlacement,
NoiseBasedCountPlacement and NoiseThresholdCountPlacement are
RepeatingPlacements: they emit the same position N times, and the scatter
is a separate modifier downstream. List order decides the outcome —
count-then-scatter gives ten attempts in ten places, scatter-then-count ten attempts at one spot.
Y is set late. Positions travel through most of the chain at the world’s
minimum Y; a HeightmapPlacement or a HeightRangePlacement is what puts
them on the ground, and a chain that forgets one places at the bottom of the
world. WorldGenRegion.getHeight returns the stored height plus one, so
a heightmap placement lands on top of the surface rather than in it. Two of
vanilla’s heightmap presets read the worldgen heightmaps, which are not
among the four ChunkStatusTasks.buildTerrain primes as the terrain step ends
(the six heightmaps).
The biome is checked twice. A feature was selected because some biome
in the 3×3 wanted it; BiomeFilter re-reads the biome at the scattered
position and asks whether that biome’s generation settings contain this
exact placed feature. Without it every biome would bleed its trees a chunk in
each direction. Vanilla is not consistent about where it goes: the base tree
placement ends with the biome filter and the survival-checked variant appends its block predicate after it, while the plains trees put the predicate first, so both orders ship.
What every one of them is handed is a PlacementContext, which carries the level, the generator and — the field the biome filter needs — the placed feature the chain started from, and reads the block state at a position and a heightmap through the level.
Eighteen modifier types are registered, and this page has named ten of them —
the seven in the chain and three more beside it. The shapes account for all eighteen. Six implement PlacementFilter —
the four in the chain, SurfaceRelativeThresholdFilter, which keeps a position only if its height falls within a range of a heightmap reading, and
RandomChancePlacement, which keeps it on a fractional chance. Three are
RepeatingPlacements and two set Y. Four move or replace a position: InSquarePlacement scatters within the chunk, OffsetPlacement shifts it, EnvironmentScanPlacement steps up or down to a block a predicate accepts or drops it, and FixedPlacement swaps it for those of the absolute positions it names that lie in its chunk. The last three fit no shape at all:
CuboidPlacement fans a position out into a box, RandomlySelectedPlacement
runs one of its modifiers at random, and CountOnEveryLayerPlacement, deprecated,
does its own scatter and cave-layer scan inside PlacementModifier.modify.
A feature that is a tree of features
Six of the fifty-eight registered features exist only to choose between other
placed features or to run them in turn, writing no blocks of their own, which
is how a data pack builds decoration out of decoration rather than out of
algorithms:
RandomSelectorFeature walks a weighted list rolling each entry’s chance and
falls back to a default; SimpleRandomSelectorFeature picks a uniform index;
WeightedRandomSelectorFeature draws from a weighted list;
RandomBooleanSelectorFeature flips a coin between two;
SequenceFeature places every entry in order and stops at the first
failure, reporting failure itself; and OverlayFeature places every entry
whatever the others did, reporting success if any entry succeeded. The plains oak is one
of these — a random selector between a fancy oak, a fallen oak and a plain oak
with bees. (One more writes nothing and chooses nothing: NoOpFeature is a
deliberate blank, and it is what an empty slot in a data pack is spelled as.)
All six reach FeaturePlacer.place, directly or through PlacedFeature.place,
and never FeaturePlacer.placeWithBiomeCheck — which is exactly why a
BiomeFilter inside a nested placed feature is an error rather than a no-op. The filter
needs the context’s top feature, and only the biome-check entry sets it. That
is why the “checked” tree placements carry no biome filter and the
biome-level ones do.
What a feature may write, and where it may read
ChunkStatus.FEATURES is the only step in the generation pyramid with a
positive block write radius, and the radius is one — so a tree may cross
into a neighbour, and nothing else in generation may cross into anything
(two steps may write, and only
two).
What that permission is worth is decided one write at a time. Nothing checks a
feature’s origin before it runs; each individual write is checked by
WorldGenRegion.ensureCanWrite, which logs — and pauses, in a development
environment — and does not write. A canopy that would reach two chunks out is
therefore truncated, not moved and not abandoned: half a tree, each refused block one line in the log. Reading is looser and then suddenly much stricter. A read outside the write zone is logged at error level and still happens; a read past the step’s
declared dependency radius — eight chunks at this step, and only of chunks at
ChunkStatus.STRUCTURE_STARTS — throws instead of loading, which is what makes
cascading worldgen structurally impossible rather than merely discouraged
(a read too far crashes, a read too wide is only logged).
A radius of one in both directions means a chunk can go on changing after it has decorated: any of its eight neighbours that decorates later may write into it, and nothing in the dependency graph says that is safe. The graph fixes what a step may assume of its neighbours — ChunkStatus.FEATURES asks them only to have reached ChunkStatus.TERRAIN, and no step may ask a neighbour for its own status — not which of two neighbours decorates first, which is whichever generation task reaches it first; so a tree near the border finds the other chunk’s tree already standing, or not, by the order the two decorated in. Nor does the graph say who is inside the chunk at once. What makes it safe is that a dimension’s decoration steps run one at a time on its one worldgen executor, so no two neighbours are ever inside the centre chunk at once
(dispatch, and why the parallelism is smaller than the thread
names).
The supporting value types are worth naming because they sit on a ladder of
three rungs, and each rung is how much world its types are trusted with. On the
first, IntProvider and FloatProvider get a random source and nothing else.
On the second, HeightProvider.sample and VerticalAnchor.resolveY get a
WorldGenerationContext, which is three integers — the world’s minimum Y, its
height and its sea level. Only the third sees the world, and two families share
it. A BlockStateProvider gets the level as well as a random source and a
position, and picks the block to write from them; BlockPredicate extends
BiPredicate<LevelAccessor, BlockPos>, which is why a placement can ask what block is under the sapling — and why BlockPredicateFilter is the chain’s general test of what is already there. Every one of those types but VerticalAnchor is its own registry of dispatched types, and
every registry is mostly instances: sixteen block predicates, three of them the any_of, all_of and not combinators over the rest; six height providers, most of them distribution shapes over one range; ten state providers, which pick a block from a fixed state, a weight, a noise or a rule, or adjust the one another provider picks. The head of each family is explained once,
here; the members are a catalogue this book declines
(what this book skips).
Questions players ask
Does a sapling grow the same way a worldgen tree does? No — it skips this whole page. Decoration is one door into a feature and is everything above; /place feature is another, and places a feature outright with no placement layer at all; and a sapling’s growth is a third, beside the other growing blocks, the bonus chest and the End’s own features: SaplingBlock.advanceTree, from a random tick or from bone meal, runs on the Server thread and call Feature.place on the ServerLevel directly, so there is no placement chain, no biome filter and no write guard (WorldGenLevel.ensureCanWrite is an interface default that is always true, and nothing on this path asks it).
It also hand-manages the sapling block, which is a story of its own
(trees).
Where are the actual features? Fifty-eight feature types are the longest tail in the part — icebergs, geodes, dripstone clusters, lakes, springs, coral,
huge fungi, the End spikes — and none of them is a mechanism this page has not
already explained: each is one Feature.place working from its own parameters. The book names the
framework and declines the catalogue
(what this book skips); the two that do
something structurally different are named where they bite, OreFeature on
chunk anatomy
for holding a section open across many writes, and TreeFeature on
trees.
For a 1.21-era reader. ConfiguredFeature, FeatureConfiguration and FeaturePlaceContext are gone: a
Featurecarries its own parameters, and its instances are the data registry worldgen/feature. PlacedFeature.placeWithBiomeCheck is nowFeaturePlacer.placeWithBiomeCheck, PlacementModifier.getPositions is nowPlacementModifier.modify, and RandomOffsetPlacement is nowOffsetPlacement.
Where to look
ChunkGenerator.applyBiomeDecoration is the driver and holds the whole
scenario: the two reseedings, the 3x3 biome union and the sorted index loop.
Read FeatureSorter.buildFeaturesPerStep next, because it is the sort the
hook turns on, with ChunkGenerator.validate beside it, which the client calls while opening a world to meet the cycle early. Then the fold: FeaturePlacer.placeWithBiomeCheck and
PlacementModifier.modify are the whole of it, and
PlacementFilter and RepeatingPlacement are two of the shapes a modifier can take and HeightmapPlacement an example of a third — BiomeFilter last, since it is
the one that needs the context the entry point sets. PlacementContext is
what a modifier is trusted with and the arguments of Feature.place what a
feature is; reading the two side by side shows that both are handed the same level and generator, and that only the modifier is told which placed feature it serves. Finish at
WorldGenRegion.ensureCanWrite, which is where a tree gets truncated, and at
RandomSelectorFeature for a feature made of features. One door the page does
not open: BlockStateProvider, the family that picks what many features write, a tree’s trunk and leaves among them.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Trees
Verified against Minecraft 26.3 · Part XII · One sapling grows: five pluggable parts over one algorithm, a ceiling the crown’s size was decided before, and the dark-oak sapling that will never grow on its own.
Plant a single dark-oak sapling, try to feed it bone meal, and nothing
happens: the bone meal is not even spent, and left alone the sapling never
grows. Nothing is wrong with the sapling; there is simply no tree for it to
become. TreeGrower holds three weighted lists of features per species — the
trees a single sapling can become, the mega trees a 2×2 of them can become,
and a list that stands in for the first when a flower is near — and
TreeGrower.DARK_OAK fills in exactly one of them, the mega trees. The
single-sapling list is left empty, so TreeGrower.growTree finds nothing to
place, and TreeGrower.canGrow, which bone meal asks first, reads the same
empty list as a refusal unless the sapling is part of a 2×2. The best-known
growth rule in the game is implemented as an absence.
Which is a good introduction to this page, because the whole tree kit works
like that: the forty-five shipped features that are a TreeFeature are one
algorithm with five slots
in it, and almost everything you can say about how a cherry differs from a
mangrove is a statement about what is in the slots. (TreeFeatures declares
fifty-seven keys, because it also holds the fallen trees, the huge mushrooms
and the huge fungi, none of which is a TreeFeature.)
Features and placement
is how a tree gets a
position and whether it is attempted at all; this is what happens after
Feature.place is entered.
The cast
| class | its slot | what varies |
|---|---|---|
TreeFeature | the algorithm — a record, so final, one implementation, no subclasses — and the nine fields it runs on: five of them are the parts below, three hold BlockStateProviders (features and placement) for the trunk, the foliage and the soil block laid under each trunk column by every trunk placer but UpwardsBranchingTrunkPlacer, and one is the ignore vines flag | the fields, never the algorithm |
TrunkPlacer | writes the logs, returns where crowns hang | 10 registered types |
FoliagePlacer | writes the leaves around one attachment | 12 registered types |
RootPlacer | writes roots, and may lift the trunk off the ground | 1 registered type |
FeatureSize | the clearance profile — a horizontal radius per height | 2 registered types, TwoLayersFeatureSize and ThreeLayersFeatureSize |
TreeDecorator | runs afterwards over what was placed | 11 registered types |
FoliagePlacer.FoliageAttachment | the only channel from trunk to crown: a position, a signed radius nudge, a height nudge no shipped trunk placer sets, and the trunk’s width on each axis, from which is the trunk under me two-by-two is read | — |
Five of those rows are the slots — trunk, foliage, roots, size and
decorators — and every one of the five is a codec-dispatched type in a built-in
registry: TrunkPlacerType, FoliagePlacerType, RootPlacerType,
FeatureSizeType and TreeDecoratorType. So
a data pack composes trees freely and cannot add a new kind of placer
(the data-driven type pattern).
One algorithm, five slots
sequenceDiagram
participant TF as TreeFeature
participant TP as TrunkPlacer
participant FolP as FoliagePlacer
participant RootP as RootPlacer
participant WGL as WorldGenLevel
participant TDec as TreeDecorator
rect rgba(0, 0, 0, 0.04)
Note over TF,WGL: nothing written yet
TF->>TP: getTreeHeight, a base plus two draws
TF->>FolP: foliageHeight, then foliageRadius, from that height
TF->>TF: out if the tree would leave the build height
loop the clearance scan, layer by layer
TF->>TP: isFree, across the profile's radius
end
TF->>TF: out if clipped and no minimum allows it
TF->>RootP: placeRoots, simulated in full first
RootP-->>TF: false, and the tree is abandoned
end
RootP->>WGL: or roots, and the moss above them
TF->>TP: placeTrunk, with the clipped height
TP->>WGL: logs
TP-->>TF: a list of foliage attachments
loop one per attachment
TF->>FolP: createFoliage, clipped height and both crown numbers
FolP->>WGL: leaves, each at distance 7
end
TF->>TDec: place, with the logs, leaves and roots sorted by Y
TDec->>WGL: hives, vines, podzol, propagules
TF->>TF: updateLeaves, rewriting the distance of every leaf it reaches
Everything in the shaded band happens before a block is written, which is where all three ways out are; below it nothing is undone, and the crown was sized before the scan measured the room.
The two crown numbers are FoliagePlacer.foliageHeight and
FoliagePlacer.foliageRadius, and both are what TreeFeature hands to every FoliagePlacer.createFoliage call, beside the clipped height; every block any slot writes goes to
the WorldGenLevel the feature was handed. Four things in that diagram are the
page’s real content.
The crown is sized before the ceiling is measured. TreeFeature samples
the trunk placer’s proposed height, derives FoliagePlacer.foliageHeight and
FoliagePlacer.foliageRadius from that number, and only then runs the
clearance scan. The scan’s answer, the clipped height, is passed on to
TrunkPlacer.placeTrunk and FoliagePlacer.createFoliage — but the two crown
numbers travel beside it, already decided. It is a real asymmetry that no
shipped tree can express: the only species vanilla lets survive a clipping is
the fancy oak, and FancyTrunkPlacer derives its cluster count from the
clipped height, so a clipped fancy oak gets a smaller crown, not a bigger
one. The one placer whose foliage height reads the tree height at all is
SpruceFoliagePlacer, and no spruce declares a minimum clipped height, so a
spruce under an overhang abandons itself instead.
Clipping usually kills the tree instead. FeatureSize.minClippedHeight
is the only thing that permits a clipped tree at all, and in vanilla exactly
one tree declares it: the fancy oak, at four. Every other species abandons
itself the moment the scan comes back short. The scan returns two below the
first blocked layer, so an obstruction at head height yields a negative
number and nothing survives it.
The scan asks the trunk placer what “free” means, not the feature size.
FeatureSize supplies only the radius to test.
TrunkPlacer.isFree is air, anything in the replaceable-by-trees tag, or
an existing log — which is how a new tree grows up through an old one — and
it delegates to a virtual TrunkPlacer.validTreePos
(tags is where that
tag test comes from), so
UpwardsBranchingTrunkPlacer quietly widens the definition with its own
can grow through block set. A vine anywhere in the scanned column also
fails, unless the feature sets TreeFeature.ignoreVines.
Nothing rolls back. There are three places a tree can abandon itself —
the build-height check, the clipped-height check, and RootPlacer.placeRoots
returning false — and all three happen before a single block is written.
After TrunkPlacer.placeTrunk begins there is no undo, and TreeFeature
reports success even for a tree that was truncated to a stump.
The trunk placers
The base contract is three numbers — TrunkPlacer.getTreeHeight is a base
height plus two independent random draws — and one method that writes logs
and returns attachments.
| type | how it differs |
|---|---|
StraightTrunkPlacer | one column, one attachment one block above the top log |
ForkingTrunkPlacer | leans near the top, then grows a side branch in a second random direction — and if that direction happens to equal the first, the branch is skipped and the draw is spent anyway. The main fork’s attachment carries a radius nudge of +1 |
GiantTrunkPlacer | a 2×2 column, four dirt blocks beneath it, and only the (0,0) column placed on the very top layer. Its attachment sets double trunk, as DarkOakTrunkPlacer’s does |
MegaJungleTrunkPlacer | the giant trunk, plus side branches laid along a random angle every few levels, each attachment nudged −2 |
DarkOakTrunkPlacer | a leaning 2×2 trunk whose lean is two minus a draw from three, so two steps, one or none with equal chance, plus a ring of downward log stubs on a one-in-three roll per position — placed relative to the original trunk, not the leaned one. Its main attachment sits on the top log rather than above it |
FancyTrunkPlacer | see below |
BendingTrunkPlacer | rises, nudges once or twice, then walks horizontally for a sampled bend length — and emits an attachment at every position of the arc above a minimum height, including ones where the log was not placed |
UpwardsBranchingTrunkPlacer | a straight column that rolls a probability after each log and, on success, runs a diagonal staircase branch outward, attaching foliage at every branch log. The one placer that widens what counts as free |
CherryTrunkPlacer | one or two side branches that random-walk toward a computed endpoint, choosing vertical or horizontal per step by the remaining ratio, with the log axis rotated sideways for the horizontal runs — and, when the drawn count is three, a third that is the trunk going on straight up. It derives one more branch-height provider in its constructor that no codec ever sees, which is why the codec insists the declared range spans at least two blocks |
PoplarTrunkPlacer | a straight column with one to four single sideways logs stuck out at one level, a sampled number of logs below the top, and its one attachment a block above those stubs — in a shipped poplar, down inside the trunk, which the crown then wraps. It draws a fresh shuffle of the four directions for every level and uses one |
FancyTrunkPlacer is the one worth watching, because it is the only trunk placer that plans before it writes. It works out a crown position per level from a
circle equation, then walks the line from trunk to crown twice: once with
placement switched off, purely to ask whether every block on the way is free,
and again for real only if it was. Each branch’s base sits below its crown, by a fixed share of the distance out, and one that would sit above the trunk top is clamped to it, which is the whole “branches rise as they go out” look. And it carries one computation that cannot do anything: the number of
crown candidates it tries per level is a minimum taken against one, over an
expression that is never below one, so the answer is always one — and the
named density constant that expression multiplies therefore has no effect on
any tree of any height.
The foliage placers
A foliage placer gets one attachment and, beside the clipped tree height, which none of them reads, three numbers: a height, a radius, and an offset it samples itself from a configured IntProvider — how far above
the attachment its rows start. Its two real degrees of freedom are how the radius
changes with height and which positions inside a row it skips, and the skip
test is asked in signed coordinates, running from minus the radius to plus
it, then folded to absolute values before the block is placed — so a placer that
wants to treat one corner differently from its mirror image has to override the
signed form, which one placer does, or write rows of its own that never reach
it, which one other does.
| type | how it differs |
|---|---|
BlobFoliagePlacer | the plain oak blob: radius tapers by half the row index, corners clipped on a coin flip and always clipped on the row at y = 0 |
FancyFoliagePlacer | the blob’s subclass, but the skip test is a genuine circle rather than a corner roll |
BushFoliagePlacer | the blob with a much steeper taper — the full row index, not half of it |
SpruceFoliagePlacer | the saw-tooth: a radius that grows a block per row and resets to its minimum — nothing the first time, one after — whenever it reaches a ceiling that is itself climbing. The only placer whose offset adds rows rather than lifting the crown |
PineFoliagePlacer | one cone, and the only placer that overrides FoliagePlacer.foliageRadius — it adds a draw scaled by the trunk height on top of the configured radius |
AcaciaFoliagePlacer | not a loop at all: three explicit rows at two heights, with a cross cut through the flat plate. Its declared foliage height is a constant zero |
DarkOakFoliagePlacer | two explicit rows, or three or four when the trunk is 2×2, wider with it, and the only placer that overrides the signed skip test with a working one — on the widest row of a two-by-two tree it skips the nine positions whose signed coordinates are each −r, r or r + 1 (the grid runs from −r to r + 1): the four true corners and the five one block in from them on the positive side, whose mirror images are kept |
MegaJungleFoliagePlacer | registered as jungle_foliage_placer, not mega_jungle. A circle plus a hard Manhattan cap that skips anything seven or more blocks out |
MegaPineFoliagePlacer | the only one that iterates absolute world Y, so it can make its taper jagged by widening every other row |
RandomSpreadFoliagePlacer | never places a row. It fires a configured number of shots at a box, each coordinate the difference of two draws, so the leaves cluster toward the attachment and thin out. Its skip test is unreachable dead code, and it ignores the offset the base class sampled for it |
CherryFoliagePlacer | two narrowing cap rows, a stack of full-radius rows, then the only two uses of the hanging-leaves row helper. It punches probabilistic holes: an edge hole on the row at y = −1, the wider of its two hanging-leaves rows, and on every row of radius three or more an unconditional corner removal plus a probabilistic diagonal band |
PoplarFoliagePlacer | a diamond rather than a square: each row keeps what lies within a Manhattan distance of the centre, two diagonally opposite quadrants reaching a block further than the other two, a coin flip per crown choosing the pair, and a configured chance of a hole at each rim position. It runs rows of its own and overrides both inherited skip tests to throw, and it is the only placer that writes logs — a cross of sideways logs laid through one row of a wide enough crown, over leaves it has just placed |
Two of the contract’s parameters are dead in all twelve implementations:
FoliagePlacer.createFoliage’s tree height, and the TreeFeature that
FoliagePlacer.foliageHeight receives. Nobody reads either.
Roots, and the tree that plants itself by failing
RootPlacerType registers one type. MangroveRootPlacer is the only root
placer in the game, and the base class exists for it: RootPlacer.trunkOffsetY is what lifts the trunk clear of the mud (one to three blocks for mangrove, three to seven for the commoner tall_mangrove), and
RootPlacer.aboveRootPlacement carries the one optional decoration a root
gets — AboveRootPlacement, the moss carpet that lands on top of one — while
the mangrove’s own parameters sit in MangroveRootPlacement.
It simulates the whole root system before writing anything. Starting from the trunk position it recurses outward in each of the four horizontal directions; each step’s candidates are straight down, sideways, or both, depending on how far out the walk already is and a skew roll. The termination rule reads backwards: the recursion returns true only when it has run out of placeable candidates, and reaching the maximum root length returns false — which propagates all the way out and abandons the tree. A mangrove is therefore planted only where its roots find solid ground before they run out of length.
One asymmetry inside it: a root that lands in mud is written from the muddy provider instead, and that branch skips the base implementation entirely — so muddy mangrove roots never get their moss carpet.
The decorators, and the pass that runs around them
TreeFeature accumulates four sets as it writes — roots, logs, leaves and
decorations, each filled through a consumer the feature hands down to whichever
placer is writing — and passes the first three to each TreeDecorator as a
TreeDecorator.Context, which sorts all three ascending by Y. That sort is the reason five different decorators can say “the lowest log” and mean the bottom of the set — usually the soil block laid under the trunk, which goes into the log set too.
A decorator returns nothing, so one that finds no valid spot is
indistinguishable from one that succeeded.
Eleven types, in four groups. Seven hang something off the tree, six of
them in a space that was empty: TrunkVineDecorator and LeaveVineDecorator
(vine curtains), PaleMossDecorator, CocoaDecorator, AttachedToLeavesDecorator
— which blacklists an exclusion box around each placement so the propagules
cannot crowd each other — and BeehiveDecorator, which puts its nest in an air
block beside a log and populates the block entity with two or three bees on the
spot (block entities);
the seventh, ShelfMushroomDecorator, wants a replaceable spot rather than an
empty one, with no water in or beside it, and works a fallen log as well as a
standing trunk. One changes a block the tree has already placed:
CreakingHeartDecorator shuffles the tree’s logs and converts one that is
completely surrounded by other logs — a random such log, not the first. Two
write on the ground around the tree: AlterGroundDecorator (the podzol discs under a mega spruce, which reach several blocks beyond the trunk) and PlaceOnGroundDecorator (leaf litter, over an inflated box) — and PaleMossDecorator, besides its hanging moss, lays a patch on the ground at a configured chance (four in five in both shipped pale oaks), a whole moss-patch feature placed at the trunk’s foot whose blocks go into none of the tree’s sets. And one is not
a tree’s at all: AttachedToLogsDecorator belongs to FallenTreeFeature.
Then the last of the feature’s own steps, and it is the one that reaches furthest.
TreeFeature.updateLeaves runs a bucketed breadth-first walk out from the
log set and rewrites BlockStateProperties.DISTANCE on every block with that property it reaches inside the tree’s bounding box. Three consequences follow, and the first two are visible in game. A neighbouring tree’s leaves caught inside
the box get rewritten too. Blocks in the prevents-nearby-decay tag report
distance zero and act as extra roots for the walk. And the decoration and root sets are marked as occupied before the walk starts, which changes nothing a player sees — no shipped decorator’s or root’s block would carry the walk anyway — and matters to the shape update that follows. Anything the walk cannot reach within six steps keeps the
BlockStateProperties.DISTANCE of 7 the foliage provider gave it — already decaying — and falls
apart on its first random tick
(random ticks).
None of that clearance machinery is shared with the rest of decoration — the
scan is TreeFeature’s alone — but the final shape update is StructureTemplate.updateShapeAtEdge, the pass that fixes block shapes at the edge of a placed structure, and every block the four consumers write goes in with one flag word: update neighbours, update clients, and known
shape (the flag word).
Five species, side by side
| trunk | foliage | roots | clearance | decorators | |
|---|---|---|---|---|---|
| oak | straight, 4 + two draws | blob, radius 2 | — | two layers | — |
| fancy oak | fancy, base 3 | fancy, radius 2, offset 4 | — | two layers, min clipped 4 | — |
| dark oak | dark oak, 6 + draws | dark oak, radius 0 — all the width is hardcoded in the placer | — | three layers | — (pale oak adds moss, and a creaking heart) |
| cherry | cherry, 7, one to three branches | cherry, radius 4, two hole chances and two hanging-leaves chances | — | two layers | — (a 5% bee-nest variant exists) |
| mangrove | upwards branching, per-log branch probability | random spread, 70 shots | mangrove, trunk lifted 1–3 (3–7 for tall_mangrove) | two layers | vines, propagules, a 1% bee nest |
The columns nobody expects to matter are where the personality lives: dark oak’s configured leaf radius is zero, and mangrove’s foliage placer is the one that does not place rows.
Questions players ask
Why does a bone-mealed oak sometimes come out enormous? Because
TreeGrower draws the tree from a weighted list — a plain oak most of the
time, a fancy oak one time in ten — and separately checks for a flower within
a 5×3×5 box, which swaps in the list of bee-nest variants. The same
mechanism is why a spruce sapling grown as a 2×2 is a mega pine half the time
rather than a mega spruce.
Why does a player-grown pale oak have no creaking heart?
TreeGrower.PALE_OAK’s mega tree is the bone-meal variant of the
feature, which is the one with no decorators on it at all. The
moss and the heart only arrive on a worldgen pale oak.
Why does a mangrove propagule grow underwater? Sapling growth is the
other entry into this machine: SaplingBlock hands off to
TreeGrower.growTree, which hand-manages the saplings on the way in. Whether
it grows one sapling or a 2×2 of them, it replaces each with whatever the fluid
there would be, so a waterlogged propagule grows into water, and it puts them
all back if the feature fails.
Do leaves know which tree they came from? No. Nothing in the placed tree records which feature grew it; a leaf’s only per-block state is BlockStateProperties.DISTANCE, BlockStateProperties.WATERLOGGED and the persistent flag, the first written by the feature’s own breadth-first pass and the second taken
from what was already in the world. A log carries a BlockStateProperties.AXIS the placer
that wrote it chooses, and that is the whole of it. FoliagePlacer.tryPlaceLeaf also refuses to overwrite a leaf a
player placed, by testing the persistent flag.
For a 1.21-era reader. TreeConfiguration is gone:
TreeFeatureis a record that holds the nine fields itself, and each shipped tree is oneTreeFeatureinRegistries.FEATURE, with no configured feature around it.TreeGrower’s secondary chance and its six optional slots are gone too; three weighted lists do their work.
Where to look
TreeFeature.place is short and is the whole algorithm: the two crown numbers
taken before the scan, the scan, the three abandonment points, and the four
sets. Read TreeFeature.CODEC beside it for the nine fields, and
FeatureSize.getSizeAtHeight with TwoLayersFeatureSize for the clearance
profile the scan tests against. Then one placer per slot, and the most
instructive are not the simplest: TrunkPlacer.isFree for what free means
and who gets to widen it, FancyTrunkPlacer for the only trunk placer that plans before it writes, FoliagePlacer.createFoliage with
FoliagePlacer.shouldSkipLocation for the row-and-skip contract and
DarkOakFoliagePlacer for the one working override of it, and
MangroveRootPlacer.placeRoots for a recursion whose success condition reads
backwards. Finish at TreeFeature.updateLeaves, which reaches further than
anything else on the page, and at TreeGrower.growTree for the other door in.
One door the page does not open: TreeFeatures, where all fifty-seven shipped
keys are declared in one file.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Structure placement
Verified against Minecraft 26.3 · Part XII · A village is decided: a lottery that never looks at the world, a layout that is deferred and then run by the method that deferred it, an absence stored as a hole, and a command that generates chunks to answer a question.
Type /locate structure #village and the answer usually comes back at once, from a couple of thousand blocks away, in a direction you have never been — naming a chunk nobody has visited. It can do that because
whether a village could be here is pure arithmetic on the world seed. No
biome is consulted, no terrain is sampled, no chunk is read. Divide the chunk
coordinates by a spacing, seed a random source from the level seed and the grid
cell, draw two offsets, and compare. Everything the world has a say in — the
biome, the ground height, whether the layout fits — happens afterwards, and
can still say no.
A structure is a thing the generator decides to build at a place rather than from it. This page is the framework all sixteen structure types share: the decision, the caching, the reference scan, the way terrain bends around it, and the moment blocks are finally written. What builds the pieces is one of two assemblers — jigsaw and templates for villages and their relatives, hand-built structures for the other fifteen types.
The cast
| class | the decision it owns | when |
|---|---|---|
StructureSet | which structures share a grid, with weights, and which StructurePlacement lays that grid out | data pack, Registries.STRUCTURE_SET |
StructurePlacement | where the grid falls, dispatched through BuiltInRegistries.STRUCTURE_PLACEMENT, which holds each kind’s MapCodec, like any data-driven type (the pattern). RandomSpreadStructurePlacement is the spacing-and-separation lottery; ConcentricRingsStructurePlacement is strongholds | world start, then per chunk |
ChunkGeneratorStructureState | which sets are possible in this dimension at all, and the stronghold ring positions | once per dimension — the filter on the Server thread, the ring searches on the worker pool |
Structure | the settings wrapper: allowed biomes, spawn overrides, the decoration step, the terrain adjustment — and Structure.findGenerationPoint. Its StructureType is what the sixteen concrete subclasses are registered as | Registries.STRUCTURE, then ChunkStatus.STRUCTURE_STARTS |
StructureStart | the answer: a structure, the chunk it started in, a PiecesContainer, a reference count and a cached box | stored on the chunk |
StructureManager | the per-level view of starts and references. Two unrelated things carry that shape of name and both are reached from the level: this one is ServerLevel.structureManager, while ServerLevel.getStructureTemplateManager returns the server’s .nbt template loader (jigsaw and templates) | worldgen and the Server thread |
StructureCheck | the presence cache — three caches over a partial-NBT reader — and the thing /locate asks | Server thread only, unsynchronised |
Beardifier | how much the terrain bends, as a density term | built with the NoiseChunk at ChunkStatus.TERRAIN |
Five decisions, on five different clocks
flowchart TD
W["world start: filter the sets, place the rings"]
W -->|"after EMPTY"| S1["STRUCTURE_STARTS: the lottery, then the layout"]
S1 -->|"next status"| S2["STRUCTURE_REFERENCES: scan the 17x17 around"]
S2 -->|"BIOMES"| N["TERRAIN: the Beardifier bends the field"]
N -->|"next status"| F["FEATURES: the pieces write their blocks"]
Five decisions, one at world start and four on the chunk-status ladder, with the statuses between them on the arrows: a structure is decided at the second status and writes no block until the biome and terrain statuses are past.
The odd thing about that ladder is where it starts. ChunkStatus.STRUCTURE_STARTS
is the second status a chunk passes through, two before
ChunkStatus.BIOMES (the pyramid,
drawn is the ladder
and the dependency rules it enforces) — so a structure is decided before the
biomes and the
terrain it will sit in exist. Everything the structure needs to know about
the world it asks for directly, from the generator, rather than reading it
out of a chunk.
Which chunk: arithmetic, and the two places a biome still gets in
ChunkGenerator.createStructures walks the possible structure sets. For a
village that means RandomSpreadStructurePlacement.getPotentialStructureChunk:
divide the chunk coordinates by the spacing, seed a WorldgenRandom from the
level seed, the grid cell and the set’s own salt, and draw two offsets inside
the cell. This chunk is the village chunk only if the draw lands exactly
here. RandomSpreadType decides whether the draw is uniform or triangular,
and AbstractSpreadingStructurePlacement — the class both placement types the shipped sets use extend — adds in AbstractSpreadingStructurePlacement.isStructureChunk a frequency roll, an
AbstractSpreadingStructurePlacement.FrequencyReductionMethod and a deprecated
AbstractSpreadingStructurePlacement.ExclusionZone that lets one set repel another.
Two qualifiers, and both matter. The other placement type the shipped sets use
is not like this at all: ConcentricRingsStructurePlacement positions strongholds by asking
BiomeSource.findBiomeHorizontal for real biome positions, on the worker pool, at world start. And even for villages a coarse biome test has already
happened once — when ChunkGenerator.createState built the
ChunkGeneratorStructureState and dropped every set no biome in this
dimension can host.
Which structure, and whether the biome allows it
The set has entries with weights, so a second WorldgenRandom picks one.
Then Structure.findValidGenerationPoint runs
Structure.findGenerationPoint and filters it through
Structure.GenerationContext.isValidBiome — the biome is sampled at the proposed point,
after the lottery has already chosen the chunk.
If that fails — most often the biome, but also an empty start pool, a missing start jigsaw, or a structure that would sit too close to the world height limits — the entry is removed, its weight subtracted, and the roll repeated. A village slot whose first pick fails its biome test therefore still gets whichever of the other villages the ground allows, where a single-candidate set would get none. If every entry fails, the loop drains and the cell stays empty: the slot still exists, the village does not.
The layout is deferred, and the centre is not
For fifteen of the sixteen types, Structure.findGenerationPoint does not return pieces. It returns a Structure.GenerationStub, and the stub holds the child expansion as an unexecuted consumer. Structure.generate calls Structure.GenerationStub.getPiecesBuilder as soon as the stub has passed its biome test, so on the generation path the deferral lasts no time at all. What the deferral is actually for is StructureCheck.canCreateStructure, which calls
Structure.findValidGenerationPoint and asks only whether the result is
present. So a presence question costs a centre and never a layout: the child
expansion runs once, on the chunk that is really generating, and never for the hundreds of candidate chunks a search walks past. The sixteenth is the mineshaft: MineshaftStructure.findGenerationPoint builds its whole piece graph before it returns, so a map that hunts a mineshaft lays one out, on the Server thread, for every candidate in unexplored ground that passes the set’s frequency roll (hand-built structures).
For a jigsaw, what is not deferred is the centre: the start template, its rotation and its ground height are all resolved before the stub comes back — which is why a presence question is not free either, merely much cheaper than a village.
One name in this package reads as the framework for all of that and is
reached by nothing: PostPlacementProcessor. The live
post-placement hook is Structure.afterPlace.
The presence cache, and the hole that proves an absence
StructureCheck is the cache in front of all of this, and it is three caches
over a partial-NBT reader: chunk → structure → reference count (which is
what makes “unreferenced only” searches possible), structure → chunk →
would-generate, and a capped set of the chunks storage held no starts for,
which spares the disk a second read of them. On a miss it reads the chunk off disk through
ChunkScanAccess, which answers a question about a chunk without loading it
and is one of the three joins that block the Server thread (the three places
that do wait),
pulling only the data version and the structure starts — a field-selected read
that never builds the rest of the tag
(a whole file need not be read) —
and data-fixing that fragment alone. It is Server-thread-only and unsynchronised, which is why both routes in hop to the Server thread to feed it, through ServerLevel.onStructureStartsAvailable: ChunkStatusTasks.generateStructureStarts on the worldgen executor when a chunk generates its starts, and the loading pyramid’s own ChunkStatusTasks.loadStructureStarts when a chunk arrives from disk with starts already in it.
Absence is stored as a hole, not as a marker. An invalid start is never
written at all: ChunkGenerator.tryGenerateStructure calls
StructureManager.setStartForStructure only for a valid start, and
StructureStart.INVALID_START is dropped on the floor. What lets a partial
scan prove absence is that every saved chunk carries a starts compound
unconditionally, empty or not — so a structure simply missing from that map
is a definite “not here”. The INVALID ids that do turn up in old saves are
legacy, and StructureCheck skips them while loading.
Which chunks have to be told
At ChunkStatus.STRUCTURE_REFERENCES, ChunkGenerator.createReferences scans
the 17×17 chunk square around each chunk and records the packed position
of every start whose bounding box overlaps it. Discovery is outside-in: a village never walks its own pieces to announce itself, and this is why the steps that read structures — this one, which reads the starts of the 17×17 around it to write the references, and TERRAIN and FEATURES, which read starts back through them — require structure starts eight chunks out; BIOMES, between them, reads none and declares the same radius, so four of the ten do.
The box that scan tests is not always the box the assembler produced.
Structure.adjustBoundingBox inflates it by twelve blocks on every side the
moment TerrainAdjustment is anything but none — and that inflated box is what the reference scan, StructureManager.getStructureAt and every spawn override that asks for the full box see. The margin the beardifier needs is therefore also the margin in which a pillager outpost counts as “here” for its spawn override; a village declares no spawn override at all. A 128-block cage keeps a 17×17 scan sufficient, and it is enforced when the data pack loads, not when the structure generates: a jigsaw whose maximum distance plus the
terrain margin exceeds JigsawStructure.MAX_TOTAL_STRUCTURE_RANGE fails
validation.
The ground bends before the ground exists
Beardifier.forStructuresInChunk reads those references and turns the nearby
pieces into Beardifier.Rigid boxes plus their junctions. It is built with
the NoiseChunk at the start of NoiseBasedChunkGenerator.buildTerrain, the worker-pool job behind ChunkStatus.TERRAIN (what each status hands
on), and rides in its
sampling context under Beardifier.CONTEXT_KEY — so the beardifier is in
place before the first sample of the density field it bends.
No blocks are edited. The flat shelf under a village is a term added to
the scalar field before anything samples it, and TerrainAdjustment picks the
shape: for its pieces, only two of its five values use the kernel the name beard refers to — the two beard modes. Every jigsaw junction uses that kernel too, whatever the mode, at half the weight a beard-mode piece gets, which is where the smooth shoulders under village streets come from. For their pieces, bury and encapsulate use a plain linear distance falloff instead. The rigid filter
applies only to jigsaw pieces, which have a projection to test; a hand-built
piece contributes unconditionally.
Many structures never reach any of that. A structure that names no
terrain_adaptation defaults to TerrainAdjustment.NONE and is filtered out
before its pieces are looked at, and that is twenty-three of the fifty-two
shipped structure files — the desert pyramid and the mineshaft among them,
which is why neither leaves a shelf. The hand-built pieces that do reach the
branch belong to the stronghold and the nether fossil.
Then the blocks arrive, one chunk at a time
At ChunkStatus.FEATURES, ChunkGenerator.applyBiomeDecoration places
structures at their declared decoration step, before that step’s features —
inside the same step loop that runs decoration
(the trace: a chunk decorates).
StructureStart.placeInChunk derives a reference position from piece
zero — piece order is semantic, not cosmetic, and every PosRuleTest
measures its distances from that point — and calls StructurePiece.postProcess
on every
piece overlapping this chunk’s writable area. Every chunk the structure
touches does this with its own box, so a house straddling four chunks is written in four slices, at four different times, and a piece’s StructurePiece.postProcess must write only inside the box it is handed.
What /locate asks, and what it answers with
Occasionally the command stops for a second before answering, and that is the search’s disk reads and chunk generation being paid for on the Server thread. StructureCheck re-runs the start-point and biome test — the grid arithmetic having already produced the candidate chunk — and answers StructureCheckResult.CHUNK_LOAD_NEEDED for every candidate in unexplored terrain that passes it, and the search then loads that chunk to structure starts, synchronously, out to a radius of a hundred grid cells for the command and a cartographer’s map, and fifty for a dolphin and a map found in a chest. The eye of ender passes a hundred too, but its one target is the stronghold, and a ring placement’s search takes no radius: it tries each ring position nearer than the best found so far. An eye of ender, a dolphin and an explorer map
all reach the same code and can all pay the same pause.
What comes back is not the structure. ChunkGenerator.findNearestMapStructure
returns StructurePlacement.getLocatePos, which is the start chunk’s minimum
block plus the placement’s own offset, and the eye of ender takes the same
answer. A stronghold’s portal room can be well over a hundred blocks from it, and the
stronghold does keep a pointer at the room that the search never reads
(hand-built structures).
An exploration map asks a sharper version of the same question, and the
reference count is what makes it answerable. StructureStart.getMaxReferences
is one, and ExplorationMapFunction defaults skip_existing_chunks to true,
so a map asks for an unreferenced structure and takes a reference when it
finds one — which is exactly what the first of StructureCheck’s three caches
stores, and why two maps usually do not send two players to one monument. It
is a default and not a guarantee: the three buried-treasure tables, in
shipwrecks and both ocean ruins, set the flag to false and will happily send
both.
Where to look
Structure.generate is the entry and the whole shape in one method: it calls
Structure.findValidGenerationPoint, which is the lottery’s answer filtered by
biome, and hands back the stub. Read Structure.StructureSettings beside it for
what a structure declares. Then the placement side — StructureSet,
AbstractSpreadingStructurePlacement.isStructureChunk and
RandomSpreadStructurePlacement.getPotentialStructureChunk — which is the grid
arithmetic, with ConcentricRingsStructurePlacement as the shipped one that does not
work that way. ChunkGenerator.createStructures and
ChunkGenerator.createReferences are the structure’s first two chunk statuses, in that order, and StructureStart.placeInChunk is its last. StructureCheck.checkStart is the
cache, and ChunkScanAccess under it is the partial read that makes it cheap.
Finish at Beardifier.forStructuresInChunk and TerrainAdjustment, which are
how the whole arrangement reaches the terrain. Two doors the page does not
open: BuiltinStructures and BuiltinStructureSets, where the keys of the
fifty-two shipped structures and their twenty-one grids are declared.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Jigsaw and templates
Verified against Minecraft 26.3 · Part XII · A village assembles itself: pieces that find each other through connector blocks, a priority queue instead of a stack, and a growth limit that takes the right pool away a piece before it stops the queue.
A village stops somewhere. Follow a street out from the town centre and the
houses run out and the path ends in a stub of dirt path with nothing on it.
Nothing measured the street and nothing counted the buildings. That stub is
a terminator, and it comes from the street pool’s fallback pool — which
JigsawPlacement.Placer appends to the candidate list at every depth,
behind the pool the piece asked for. A street ends wherever the
street pieces stop fitting. What the depth limit — a size the structure
declares, six for a village against the twenty JigsawStructure.MAX_DEPTH
allows — does is stop offering the asked-for pool at all, so at the limit the
fallback is the only thing left, and stop queuing whatever a piece there places. The edge of a village is a substitution first, and a
stop condition only one piece further out.
This is the assembler one of the sixteen structure types uses — the jigsaw —
together with the .nbt template system that turns each of its pieces into
blocks, which is also what a structure block reads and writes. Everything
outside the assembler is structure placement:
the lottery that
chose this chunk, the reference
scan,
the beardifier,
and the moment StructurePiece.postProcess is
called. The
other fifteen types use a
different assembler and reach this page only for the templates
(hand-built structures).
The cast
| class | what it holds | when |
|---|---|---|
JigsawStructure | ten data-pack fields: the start pool, the start jigsaw name, a depth, a start height, a heightmap to project onto, a maximum distance, the pool aliases, the expansion hack, the dimension padding and the liquid settings | data pack, Registries.STRUCTURE |
StructureTemplatePool | a weighted list of StructurePoolElements and a fallback pool — the two fields its codec has | data pack |
StructurePoolElement | one candidate: a single template, a legacy single, a list, a placed feature, or nothing — a dispatched type like the processors and the rule tests below (the pattern) | data pack |
JigsawPlacement.Placer | the body of the assembly loop, and the priority queue that loop drains; the VoxelShape of free space (shapes and collision) travels with each queue entry | ChunkStatus.STRUCTURE_STARTS, on the worldgen executor |
JigsawBlock | the connector. Five things are written on one: its own name, the target name it looks for, the pool to draw a neighbour from, the final state it turns into once the assembler is done with it, and a JigsawBlockEntity.JointType deciding whether rotation must match, which matters only on an up- or down-facing one. Two priorities sit beside them, below | in the template |
PoolElementStructurePiece | one accepted candidate, with its junctions | built at ChunkStatus.STRUCTURE_STARTS, saved with the chunk |
StructureTemplate | a parsed .nbt file: block palettes, entities, and the jigsaw blocks in it | loaded by StructureTemplateManager |
StructurePlaceSettings | rotation, mirror, the chunk box, liquid handling and an ordered list of StructureProcessors | per piece, per chunk |
A village assembles, from town centre to blocks
sequenceDiagram
participant ChunkG as ChunkGenerator
participant JS as JigsawStructure
participant JP as JigsawPlacement
participant JPP as JigsawPlacement.<br/>Placer
participant PESP as PoolElement<br/>StructurePiece
participant STemp as StructureTemplate
Note over ChunkG,STemp: ChunkStatus.STRUCTURE_STARTS, on the worldgen executor
ChunkG->>JS: Structure.generate, the lottery already won
JS->>JP: addPieces, from findGenerationPoint
JP-->>JS: a town centre on the ground, and a stub
JS->>JP: the stub's consumer, once the biome test passes: the free-space shape
JP->>JPP: tryPlacingChildren, on the centre first
loop until the priority queue drains
JPP->>JPP: a piece's jigsaw blocks, shuffled, then by selection priority
alt below the limit
JPP->>JPP: the target pool's elements, then the fallback's
else at the limit
JPP->>JPP: the fallback's elements only
end
JPP->>JPP: the attach test
JPP->>ChunkG: getFirstFreeHeight, unless both pieces are rigid
JPP->>JPP: collide with the free shape, then subtract the box
JPP->>PESP: addJunction, on the parent and on the child
JPP->>JPP: queue the child by placement priority, unless its parent was at the limit
end
JS-->>ChunkG: a StructureStart holding every piece
rect rgba(0, 0, 0, 0.04)
Note over ChunkG,STemp: ChunkStatus.FEATURES, once per chunk the village touches
ChunkG->>PESP: postProcess, through StructureStart.placeInChunk
PESP->>STemp: placeInWorld, through its pool element, clipped to this chunk
end
A village is a queue drained in memory at the second status and written chunk by chunk at ChunkStatus.FEATURES; at the depth limit a piece is still queued, and only the fallback pool is offered to it.
The alternative box and the loop’s last arrow are the page’s opening in one frame: nothing in the loop counts houses or measures a street, and the depth limit changes two things: which pool a jigsaw block is offered, and whether what a piece at the limit places is queued at all, which it is not. JigsawPlacement.addPieces does the centre and the stub, Structure.GenerationStub.getPiecesBuilder runs the stub once it has passed its biome test, and JigsawPlacement.Placer.tryPlacingChildren does everything inside the loop; the sections below take the loop’s steps in order.
The pools
A StructureTemplatePool is the unit of choice, and its codec has exactly two
fields — all 245 shipped pool files carry those two and nothing else. The
weighted elements list is the candidates. The fallback pool is a second
pool appended behind them, tried whenever nothing in the first list fits — unless that list holds an empty element, which ends the search wherever the shuffle put it, fallback and all — and the only thing tried at the depth limit. The third thing you might expect on
the pool is not there: StructureTemplatePool.Projection is a field of each
element, so one pool can mix them. It decides how Y is chosen: rigid keeps the parent piece’s vertical offset when the parent is rigid too, and terrain matching asks the generator
for the ground height and brings a gravity processor with it.
Five kinds of element can sit in that list. SinglePoolElement is one
template. LegacySinglePoolElement is the same with a wider ignore rule, which drops the template’s air. ListPoolElement is several placed as a unit. EmptyPoolElement is a
deliberate nothing. And FeaturePoolElement places a PlacedFeature
instead of a template — which is how village trees arrive
(features and placement).
PoolAliasBinding and PoolAliasLookup sit on top: one structure can swap
which pool a name resolves to, per placement, resolved once from a positional
random source before the assembly loop starts. PoolAliasBindings registers
three kinds — DirectPoolAlias names a replacement outright, RandomPoolAlias
draws one from a weighted list, and RandomGroupPoolAlias draws a whole group
of substitutions together so they agree. Trial chambers are the only vanilla
structure that uses any of them, and what they swap is which pool the spawners
draw their mobs from.
The assembly loop
JigsawPlacement.addPieces builds a VoxelShape of free space around the
start piece and hands it to JigsawPlacement.Placer. From then on the
algorithm is: take a placed piece, look at its jigsaw blocks, and for each one
try to hang something off it.
Five details in that loop are the ones worth watching.
It is a priority queue, not a stack, and two different priorities steer it.
Within one piece, its jigsaw blocks are shuffled and then sorted by
selection priority, highest first, so a mapmaker can decide which connector
on a house gets first refusal. Across pieces,
JigsawPlacement.Placer.placing orders the queue by the source jigsaw block’s
placement priority, with insertion order breaking ties — not depth-first —
so a template can have the piece hung on one of its connectors expanded before everything queued at a lower priority.
Attachment is a name match plus a geometry match. JigsawBlock.canAttach
requires the two jigsaw blocks to face each other and the source’s target name to be the candidate’s name, or the candidate to have none; an aligned joint additionally requires the rotations to match, while
a rollable one does not — a difference only an up- or down-facing pair can show.
The ground decides what fits, without a chunk being read. Whenever the
source or the target piece is not rigid — which is every village street —
JigsawPlacement.Placer asks ChunkGenerator.getFirstFreeHeight for the ground
under the source’s jigsaw block and puts the candidate’s box there, and that box
— raised first by the expansion hack where the structure sets it (below) — is what the collision test below then tests. ChunkGenerator.getFirstFreeHeight
samples the density graph rather than reading blocks
(density functions), which is
the whole reason the assembly can run at ChunkStatus.STRUCTURE_STARTS, before
any terrain has been written.
Collision is against a shrinking shape, not against a list. One
VoxelShape of free space is built around the start piece and then travels
down the queue inside each JigsawPlacement.PieceState, and every accepted
piece subtracts its own box from it — so the next candidate is tested against
what is genuinely left. A candidate that intersects is simply not built, and the candidate’s next connector is tried, then its next rotation, then the next candidate. There is one branch: when a jigsaw block points into its own piece’s box, the child is tested against a shape of that piece’s box instead of the shared one, and every inward child of the piece shares it — which is how a village street carries its houses, whose entrances point into the street’s own box.
A junction is recorded on both sides. Each connection writes a
JigsawJunction into the parent piece and the child, which is what lets
Beardifier treat junctions as their own terrain contribution rather than
inferring them from the boxes
(structure placement).
The boxes drawn round the loop
Of the ten data-pack fields on JigsawStructure, three are spatial limits on
everything above — and none of them is the depth limit, which is the size field
the hook turns on. JigsawStructure.MaxDistance is the
horizontal and vertical reach from the centre, and the free-space shape is
built to exactly that box — so a piece the queue would otherwise accept is
refused for being too far out. It is also the field the data-pack load
validates: JigsawStructure.MAX_TOTAL_STRUCTURE_RANGE is 128, and a horizontal
reach plus the twelve blocks a terrain adaptation needs must fit inside it, or
the pack fails to load rather than generating a structure the
17×17 reference scan could miss.
DimensionPadding shrinks that box at the top and the bottom, in blocks,
and rejects the start piece outright if the centre itself will not fit inside
the padded world — which is how a structure declines to generate near the
build limits instead of being clipped by them. And the expansion hack stretches a candidate at most sixteen blocks tall upward before the collision test, until it has room above its floor for the tallest piece the pools its own inward-facing jigsaws point at could need — in a village, a street raised for the houses that will stand inside its box. A street that fits can therefore be rejected for the houses it would carry.
One more field is not about space at all. LiquidSettings decides
whether the blocks a piece writes keep the water that was already there —
apply_waterlogging re-floods what it can, ignore_waterlogging leaves the piece dry — and apply_waterlogging is the default, overridable per element as well as per structure.
The shipped data is the clearest thing about the last three of those — the expansion hack, the padding and the liquid settings. Of the fifty-two structure files the game ships, twenty-four turn the expansion hack on (the five villages, the pillager outpost and the eighteen abandoned camps), and exactly one sets either of the other two: trial chambers, which pads ten blocks off the top and the bottom because it generates deep and must not punch through, and turns waterlogging off because a flooded chamber is not the room it was drawn as. No shipped pool element overrides the liquid setting at all.
The processors, and what the shipped lists use
The processors are the interesting layer, because they are shared with the
other assembler and with the structure blocks a player can use. A
StructureProcessorList is the data-pack object — a registry element that is
just an ordered list of them — and each entry is a dispatched type
(the pattern).
RuleProcessor
applies ProcessorRules, and each rule holds two block tests with
different subjects: an input predicate against the template’s own block and
a location predicate against the block already in the world. Both are
RuleTests, a family of ten ways to match a block — by state, by block, by tag, by height, or by a block or a state on a random roll, with always_true and the all_of, any_of and not combinators over the rest — and a PosRuleTest follows, its
own family, measuring position against the reference the structure’s piece
zero fixed. A replacement state and an optional RuleBlockEntityModifier come
last, and the first rule that matches wins.
BlockRotProcessor deletes a fraction of the blocks. GravityProcessor
drops them to a heightmap. ProtectedBlockProcessor refuses to overwrite
anything in a tag. CappedProcessor lets another processor change at most a sampled number of the blocks. BlockIgnoreProcessor skips a named list of
blocks, and its three presets name the structure block, air, or both — never
structure void, which never reaches a template in the first place, because a
structure block excludes it when it saves. And JigsawReplacementProcessor is
the one that cleans up after the
assembler: it swaps each jigsaw block for the state named in its final-state
string, or removes it entirely. The assembly graph is invisible in the
finished village unless SharedConstants.DEBUG_KEEP_JIGSAW_BLOCKS_DURING_STRUCTURE_GEN
is set.
Which of those a data pack uses is a much shorter list than the registry. Forty processor lists ship, and between them they name four types: rule thirty-five times, protected blocks seven, block rot six, capped four. Gravity and jigsaw replacement never appear in one, because the projection and the assembler add them; the ruined portal’s stack never appears either, because it is assembled in Java (hand-built structures).
From a piece to blocks
Nothing above has written a block. When ChunkStatus.FEATURES finally calls
StructurePiece.postProcess on a PoolElementStructurePiece, the element
assembles a StructurePlaceSettings — the chunk box, the rotation, the
ignore processor, then JigsawReplacementProcessor, then the element’s own
processor list, then the projection’s. LegacySinglePoolElement, which is
what every vanilla village piece is, then pops its ignore processor and
re-appends a wider one at the end of that list, so for the pieces a player sees the ignore step runs last and drops the template’s air as well
as its structure blocks. StructureTemplate.placeInWorld
runs every block in the template through
StructureTemplate.processBlockInfos.
A StructureTemplate is a parsed .nbt file: StructureTemplate.Palettes
of StructureTemplate.StructureBlockInfo, an entity list, and
StructureTemplate.JigsawBlockInfo for the connectors. Placing it writes
what falls inside the box, loads block-entity data
(block entities) and stamps a fresh loot
seed into containers rather than a table’s contents
(loot tables) — which is why a village chest’s
contents are decided when you open it, not when the village generated.
Where a template comes from
StructureTemplateManager loads templates in a fixed order — the world’s generated directory, then the gametest source when one is set, then data packs — through two or three TemplateSources: a DirectoryTemplateSource reading files off disk, a second one parsing the .snbt text form the game tests keep, and a ResourceManagerTemplateSource reading the pack stack like any other resource. TemplatePathFactory is what turns an id into a path when a template is saved. The
first source that answers wins, which is what lets a structure block’s saved
file shadow a data pack’s, and the folder every one of them looks in is
structure, singular.
That first source is also the seam with the block a player can use. A
structure block in save mode calls StructureTemplate.fillFromWorld over
its box — excluding structure void, which is how a saved template gets its holes — and,
saved from its screen, writes the result into the world’s generated directory; a
redstone pulse only replaces the copy the template loader holds, which generation
reads all the same. In load mode it hands a StructurePlaceSettings to the same
StructureTemplate.placeInWorld a village piece uses, with rotation, mirror,
an integrity roll that is a BlockRotProcessor, and a seed. /place template
is the command form of the same call. StructureBlockEntity holds all of that
state, and ServerboundSetStructureBlockPacket is how the screen edits it; the
jigsaw block’s editor is the same arrangement one class along.
Questions players ask
Why is one village bigger than another? Because the size field is an outer bound and almost never the thing that stops a branch. What usually ends one is every candidate in the street pool failing the collision test, which hands the branch to the fallback’s terminators — so a street that happens to run downhill into free space grows further than one that turns back on itself. The expansion hack sharpens it: a piece that would fit is rejected for the children it would need room for.
Can I watch the assembler run? Yes, two ways, and a third only a developer
sees. The jigsaw editor runs in both directions —
ServerboundSetJigsawBlockPacket and ServerboundJigsawGeneratePacket let a creative-mode operator run the assembler live against a loaded ServerLevel, and JigsawBlockEntity syncs its fields back the other way — and
/place jigsaw does the same from a command, with a pool, a target and a depth.
Neither sends the client anything shaped like a structure: it sees the jigsaw block’s fields and the blocks as they are written. The third is
DebugSubscriptions.STRUCTURES, which ships every piece’s bounding box to the
debug renderer
(debugging the running game).
Why do the same houses appear in different rotations? Because rotation is chosen per piece and applied to the block states as they are written, not to a pre-rotated template. Whether a neighbour on an up- or down-facing connector may differ in rotation is the joint type’s decision; two side-facing connectors fix it by facing each other.
For a 1.21-era reader. A structure block now saves into the world’s generated/<namespace>/structure/, not structures/ — the singular folder a data pack’s templates were already read from.
Where to look
JigsawPlacement.addPieces sets the start piece and the free-space shape;
JigsawPlacement.Placer under it is the loop’s body, and reading its one long method straight through is the page — the shuffle, the two priorities, JigsawBlock.canAttach,
the collision test, the junction on both sides. JigsawBlock.canAttach and
JigsawBlockEntity.JointType are the attachment rule in isolation.
StructureTemplatePool is two fields and worth opening for that alone, with
StructurePoolElement and SinglePoolElement for what a candidate is and
StructureTemplatePool.Projection for how Y is chosen. Then the writing half:
StructureTemplate.placeInWorld and StructureTemplate.processBlockInfos,
with StructurePlaceSettings beside them for the ordered processor list, and
RuleProcessor with ProcessorRule as the one processor worth reading in
full. JigsawReplacementProcessor is what makes the assembly graph disappear. Finish at StructureTemplateManager, for the sources and why a structure block’s file wins. One door the page does not open:
StructureBlockEntity, which is the whole editor’s state in one class.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Hand-built structures
Verified against Minecraft 26.3 · Part XII · A stronghold is generated: a piece grammar written in Java, a graph assembled at an imaginary height and moved down afterwards, and a whole structure thrown away and rebuilt because it had no portal room.
Every stronghold has exactly one end portal. Not usually, not almost always —
exactly one, in every stronghold in every world, and the grammar alone does not guarantee it. The portal room is weighted heavily, capped at one
placement, and forbidden near the entrance; and if the maze finishes without
one, StrongholdStructure clears the whole builder, adds one to the seed
and generates the entire stronghold again. It is the only structure in the
game that regenerates itself until it likes the result.
Jigsaw and templates
traces a village, and a village is a jigsaw: pieces come from a data-pack registry and find each
other through connector blocks. That is one of the sixteen structure types.
The other fifteen use an older assembler that is still the majority of the
code — thirty classes and about 10,000 lines under levelgen/structure/structures, against about 1,550 for the whole jigsaw package, aliases included. Strongholds, mineshafts, nether fortresses, ocean monuments,
woodland mansions, end cities, ruined portals, igloos, shipwrecks, ocean
ruins, desert pyramids, jungle temples, swamp huts, buried treasure and
nether fossils are all built this way.
Everything around the assembler is shared, and belongs to structure
placement: the lottery,
the presence cache
that StructureCheck is, the reference
scan,
Beardifier
and the per-chunk
write. This
page is only the part where the pieces come from.
The idea
There is no pool and no registry of pieces. A piece is a Java class that knows how to write its own blocks, and it grows the structure by constructing its own neighbours.
| class | its role |
|---|---|
StructurePiece | the base, and the reason the system holds together: a mutable BoundingBox, an orientation, a mirror, a rotation, a depth, and a registered StructurePieceType — which is how a piece comes back off disk, and the reason every one of these classes needs a loader beside it |
StructurePiece.placeBlock | the conventional write path — converts to world coordinates, drops anything outside the chunk box it was handed, applies the piece’s mirror and rotation to the block state, and schedules a tick for whatever fluid is at the position after the write. Not a choke point: the structure classes call LevelWriter.setBlock on the level directly two dozen times |
StructurePiece.BlockSelector | a stateful per-block state chooser, and the entire visual character of a structure |
StructurePiecesBuilder | accumulates the pieces, can move all of them vertically at once, and is what every piece’s StructurePiece.addChildren is handed: its StructurePiecesBuilder.findCollisionPiece is StructurePiece.findCollisionPiece, a linear scan returning the first overlapping box — there is no spatial index, and the list being scanned is the list being built |
TemplateStructurePiece | the bridge to the .nbt machinery, for structures that are procedural in layout and templated in content |
ScatteredFeaturePiece | the base for one-shot surface buildings, with two ground-finders |
SinglePieceStructure | the forty-line Structure that places exactly one of those |
Three things about that base class do most of the work.
Orientation is not independent of mirror and rotation
StructurePiece.setOrientation derives both from the facing direction, and a
south-facing piece is expressed as a left-right mirror rather than a
180° rotation. That trick is why nearly every hand-written piece in this package is written once, in a north-facing local frame, and comes out correct in all four horizontal
facings.
Local Y is measured from the box floor
StructurePiece.getWorldX,
StructurePiece.getWorldY and StructurePiece.getWorldZ map local
coordinates into the world, and because Y is relative to the floor, moving a
finished graph vertically is free. When the orientation is null the transform is the identity, which is how a mineshaft’s one room and its crossings write in world coordinates.
StructurePiece.addChildren is not a framework hook
Its default body is empty and nothing in the framework ever calls it; every call site is a structure’s own generation code. The recursion is arranged by each family for itself, in one of two shapes. Strongholds and nether fortresses use a shuffled work queue: a new piece goes into the builder and onto the start piece’s pending list, and the structure drains that list by repeatedly removing a random index and expanding it, so growth is breadth-ish and unbiased. Mineshafts use inline recursion and expand each new piece immediately, so the first branch of a crossing is fully grown before the second is attempted.
The vocabulary a piece writes with
The rest of the base class is what a piece says things in:
StructurePiece.generateBox fills a local box while distinguishing edge
cells from interior ones, and StructurePiece.generateAirBox,
StructurePiece.generateMaybeBox,
StructurePiece.generateUpperHalfSphere,
StructurePiece.fillColumnDown and StructurePiece.createChest — which
leaves a loot-table key and a seed rather than items
(loot tables) —
are the rest. StrongholdPieces.SmoothStoneSelector is the canonical block selector:
on a box edge it rolls cracked, mossy or infested stone brick and otherwise
plain, and interior cells become cave air. One small object is the whole look
of a stronghold. JungleTemplePiece.MossStoneSelector is the other one.
A stronghold, built twice if it has to be
All of this runs at ChunkStatus.STRUCTURE_STARTS
(the pyramid, drawn),
inside the same
Structure.GenerationStub
consumer the jigsaw assembler runs in — so the
whole graph is built in memory, on the worldgen executor, with no world access
and no blocks written. The stub’s generator is an either, and one structure
takes the branch nothing else takes:
MineshaftStructure.findGenerationPoint hands it a builder it has
already filled instead of a consumer to run later, so a presence check that reaches a mineshaft candidate lays the whole graph out too, on the Server thread (structure placement).
sequenceDiagram
participant ChunkG as ChunkGenerator
participant SStr as StrongholdStructure
participant SPie as StrongholdPieces
participant SPB as Structure<br/>PiecesBuilder
ChunkG->>SStr: Structure.generate
loop until the start piece has recorded a portal room
SStr->>SPB: clear
SStr->>SStr: reseed, the world seed plus one per try
SStr->>SPie: resetPieces, the static weight table
SStr->>SPB: addPiece, a new start piece
SStr->>SPie: addChildren, on the start piece
loop while the start piece's pending list is not empty
SStr->>SPie: addChildren, on a piece removed at random
SPie->>SPie: pick by weight, never the previous type
SPie->>SPB: findCollisionPiece, a linear scan
SPB-->>SPie: nothing overlaps, so construct it
SPie->>SPB: addPiece, and onto the pending list too
end
SStr->>SPB: moveBelowSeaLevel, every piece at once
end
SStr->>SPB: build, into a StructureStart
SStr-->>ChunkG: the StructureStart, written at FEATURES
The whole stronghold is grown in memory by one method on StrongholdStructure, and the outer loop is the one that throws it all away: it ends only when a try’s start piece holds a portal room.
The structure only ever asks a piece to grow; the piece does the picking, the collision test and the adding, and each piece it adds joins the start piece’s pending list for a later pass.
It is built at an imaginary height. The start piece is constructed at a
fixed Y — sixty-four for strongholds and fortresses, fifty for mineshafts —
with no idea where the ground is. Every collision test, every staircase
descent and the floor guard that refuses a box at or below Y 10 happens in that
frame. Only afterwards does StructurePiecesBuilder.moveBelowSeaLevel shift
the whole graph so its top sits below sea level. Nether fortresses use
StructurePiecesBuilder.moveInsideHeights to land in a band, and a mesa
mineshaft uses StructurePiecesBuilder.offsetPiecesVertically to sit between
sea level and the surface. This is the payoff for local-Y-from-the-floor.
The weight table is static, and so is the piece the loop is forced to place
next. StrongholdPieces.resetPieces — the reset in the diagram — refills a remaining-piece list with the whole table, zeroes the placement counts in the static weight table and clears a one-shot “place this type next” override that only the start piece ever sets, and all three are reached through static fields on StrongholdPieces, reset from inside a generation
lambda on the worldgen executor. The nether fortress shares less: its lists live on its start piece, and only the placement counts in its static weight tables are shared, reset when a start piece is constructed. Two strongholds laid out at once would interfere; what keeps it from happening is the worldgen executor, which runs one task at a time per dimension, and only the overworld has strongholds in the shipped data, so only /place structure racing generation could. It is the sharpest contrast with the stateless jigsaw path.
Growth stops when the budget is spent or no door can take a piece.
StrongholdPieces.STRONGHOLD_PIECE_WEIGHTS pairs each piece class with a
weight and a maximum placement count: most corridors and turns are unlimited, a room crossing may appear six times, a library twice, a portal room once — and
the library and portal room additionally refuse to appear before a certain
depth. The picker makes up to five weighted attempts, rejecting whichever
type was placed immediately before, and falls back to a filler corridor. Two bounds sit beside the budget — a depth cap of fifty, and a square reaching 112 blocks from the start piece’s corner on each axis, outside which no new piece may begin — and inside them the picker returns nothing once every limited type has hit its limit.
Collision is a brake too, and some pieces negotiate. Each candidate
constructor computes its box and asks
StructurePiecesBuilder.findCollisionPiece; a hit means the candidate simply
is not built. A mineshaft corridor tries decreasing lengths until one fits,
and a stronghold library falls back from its tall variant to its short one.
And the loop’s exit condition is a piece of bookkeeping that looks like a
feature. The portal room’s entire StructurePiece.addChildren body is a record
of itself on the start piece, which is how StrongholdStructure knows whether
to run again. That record is also reachable as
StrongholdPieces.StartPiece.getLocatorPosition, an override that returns the portal room’s position where the base method returns the centre of the piece’s box —
so a stronghold is the one structure in the game that knows where its own
landmark is, and nothing in 26.3 calls the method
(what /locate answers with).
The whole-graph move that ends the loop is also deprecated: StructurePiecesBuilder.moveBelowSeaLevel, StructurePiecesBuilder.offsetPiecesVertically, TemplateStructurePiece.move and the siting helper the mansion and the end city share are all marked so, and the jigsaw package declares nothing deprecated of its own. The start heights have names — StrongholdPieces.MAGIC_START_Y and NetherFortressPieces.MAGIC_START_Y at sixty-four, MineshaftPieces.MAGIC_START_Y at fifty — and one discarded random draw is load-bearing: MineshaftStructure.findGenerationPoint opens by drawing a double and throwing it away, and without that draw every mineshaft a seed generated from then on would be laid out differently.
The four families
| family | how the pieces come to exist | members |
|---|---|---|
| procedural piece graphs | the pieces write their own blocks and construct their own neighbours — the pattern in its pure form | StrongholdPieces, MineshaftPieces, NetherFortressPieces |
| grid and graph solvers | a layout is solved first and pieces are emitted afterwards, so neither ever calls StructurePiecesBuilder.findCollisionPiece — the layout is the collision guarantee | WoodlandMansionPieces, OceanMonumentPieces |
| template-backed pieces | procedural placement, .nbt content, and therefore the same processors and the same StructureTemplate.placeInWorld the jigsaw path uses | EndCityPieces, RuinedPortalPiece, OceanRuinPieces, ShipwreckPieces, IglooPieces, NetherFossilPieces, WoodlandMansionPieces |
| one-shot surface buildings | no graph and no children: one box, dropped on the ground, over ScatteredFeaturePiece, or — for buried treasure — a ground scan of its own | DesertPyramidPiece, JungleTemplePiece, SwampHutPiece, BuriedTreasurePieces |
The nether fortress is the most elaborate of the first family: it runs two
weight tables and a mode switch, where a castle entrance is a one-way door
out of bridge mode into castle mode, and only a T-balcony can fall back, on a
one-in-eight roll per branch. WoodlandMansionPieces is in the table twice
because it genuinely is: its layout is solved and its rooms are then stamped
from .nbt files.
Beside each of those piece families sits a Structure subclass — RuinedPortalStructure, DesertPyramidStructure, OceanRuinStructure, ShipwreckStructure, WoodlandMansionStructure, NetherFossilStructure and the rest — which is the settings wrapper and the entry point. For the jungle temple that is all, a SinglePieceStructure of forty lines, and the swamp hut’s, the igloo’s, buried treasure’s and the ocean ruins’ are nearly as thin; the other ten add something of their own — a siting rule, a scan for ground, a generation loop, a rebuild at load, or work after the blocks are down. Two are worth stopping on. RuinedPortalStructure draws the decay setup, below. And DesertPyramidStructure, a SinglePieceStructure too, has one of the two live Structure.afterPlace overrides (the other, WoodlandMansionStructure’s, fills the space under the mansion with cobblestone down to the ground): once the pyramid’s blocks are down it collects every candidate
position its pieces recorded, shuffles them from a positional random source,
turns five to seven into suspicious sand and the rest into plain sand — which
is why a pyramid’s archaeology is the same in two worlds with one seed and
never the same twice within one.
What a Java piece keeps that a template cannot
Three of the families hold state the framework has no place for, and each case
is a consequence of a piece being an object rather than a file. The ocean
monument’s rooms are never saved: they are held privately on the main
building, never reach the builder, and their save method is empty anyway —
StructureStart.loadStaticStart carries a hardcoded type check that calls
OceanMonumentStructure.regeneratePiecesAfterLoad, which reads position and
orientation from the save and rebuilds every room from the world seed, so the monument comes back identical rather than merely present. Every other structure’s pieces are read back from what they wrote, a template piece’s box recomputed from its template on the way. A saved bounding box is not always where the
structure is: buried treasure rewrites its own box while placing, igloos are built at a hardcoded Y 90 and re-seated at write time from the live heightmap, with only their template position put back, so a reloaded igloo’s box is recomputed at Y 90, and shipwrecks latch a flag so the second chunk does not
move them again — for those types the box is a placement hint. Two
pieces go further and deliberately widen the chunk they were given, a ruined portal and a nether fossil both encapsulating the writable area so they are placed whole rather than sliced — the portal from the chunk holding its centre, the fossil from every chunk it touches; since
BoundingBox is mutable and shared between the pieces of one start, that
widening leaks, harmlessly today because both structures have exactly one piece.
And a template-backed piece still cleans up after a connector it never
used: TemplateStructurePiece.postProcess scans what it placed for jigsaw
blocks and replaces each with its final state, so the jigsaw block inside each of five ruined-portal templates resolves quietly instead of connecting to anything.
Beardifier’s projection test is the only place at runtime where the two
assemblers are told apart.
Almost nothing here is data-driven, and that is the point. Piece choice, weights, budgets, layout rules and adjacency are Java, bar a few knobs a structure’s JSON sets: the ruined portal’s weighted setups, the ocean ruin’s odds of a large ruin and of a cluster, and the shipwreck’s beached flag, which picks its template list.
Registries.STRUCTURE still supplies the settings wrapper
(structure placement) and the templated
families read .nbt files, but a data pack cannot add a room to a
stronghold — which makes this the book’s clearest counter-example to the
data-driven type pattern: a registry whose instances are Java grammars
(the pattern).
Where the families bend the idea
The mansion is grown and then tidied to a fixed point. Corridors are recursed out from the entrance on an 11×11 grid, rooms are stamped alongside them, and then an edge-cleaning pass runs repeatedly until nothing changes, filling any cell with enough occupied neighbours. That pass is why a mansion is a solid block of building rather than the thin maze the corridor walk produced. Rooms are then greedily merged into 2×2, 1×2 and 1×1 units, with type, id and flags packed into a single integer per cell — and a room that ends up with no corridor edge becomes a secret room, reachable only from above. A mansion may also have two floors instead of three: the third needs a second-floor one-by-two room with a door to hang its staircase on, and if there is none, or no free direction to grow into, the third-floor grid is blanked entirely.
The ocean monument carves its maze backwards. It wires a lattice of rooms fully connected, then repeatedly closes a random opening and keeps the closure only if both sides can still reach the entrance room, using a depth-first reachability walk with an increasing scan counter in place of a visited set. Rooms are then fitted by a list of room-shape fitters in fixed order, first match wins, so the large double rooms get first refusal and the plain room is the fallback.
End city sections collide as groups, not as pieces. Each candidate
section is generated into a scratch list and tagged with one shared random
StructurePiece.genDepth — used as a group identity, not a depth. The
section is accepted only if every collision it finds is with a piece carrying
the parent’s tag; one foreign overlap discards the entire candidate list
atomically. A bridge tags its first span minus one, a tag no parent carries, so the tower hung off its far end may not overlap it, and the section’s shared tag replaces the mark before the bridge itself is checked; the ship becomes likelier the deeper a section sits in the city’s recursion, with at most one per city.
Ruined portal decay is mostly a processor stack. The ageing, the gold-block gaps, the lava-to-magma substitutions and the mossiness are StructureProcessors assembled per portal and rebuilt at load from the settings the piece saves, so decay reproduces exactly on reload; the netherrack spread, the netherrack columns under the portal, the vines and the leaves are the piece’s own code. What makes it unusual is that the stack is
built in Java and appears in no data pack at all
(the processors):
BlockAgeProcessor — which is the ageing and the mossiness at once —
LavaSubmergedBlockProcessor and BlackstoneReplaceProcessor are
RuinedPortalPiece’s alone. Which placement a portal gets — on the surface, partly buried, underground, in a mountain, on the ocean floor or in the nether — is settled from the setups its JSON lists before any piece exists: a weighted draw for the standard and mountain portals, which list two, and the one setup for the other five.
Where to look
StructurePiece is the whole framework and repays reading straight through:
the mutable BoundingBox, StructurePiece.setOrientation for the mirror
trick, StructurePiece.getWorldY for the floor-relative frame, and
StructurePiece.generateBox and StructurePiece.BlockSelector for the
vocabulary a piece writes with. StructurePiece.addChildren is the method to
read for what it does not do. Then StrongholdStructure beside
StrongholdPieces — the regeneration loop, StrongholdPieces.resetPieces and
StrongholdPieces.STRONGHOLD_PIECE_WEIGHTS in one sitting — and
StructurePiecesBuilder for the builder StructurePiece.addChildren is handed and
StructurePiecesBuilder.moveBelowSeaLevel for the move. After that pick one
family per shape: MineshaftPieces for inline recursion,
WoodlandMansionPieces or OceanMonumentPieces for a solver,
RuinedPortalPiece for the template-backed kind, and ScatteredFeaturePiece
with SinglePieceStructure for the one-shot buildings. One door the page does
not open: OceanMonumentStructure.regeneratePiecesAfterLoad, the only
structure whose pieces are rebuilt from the seed at load rather than read back.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Creating a world
Verified against Minecraft 26.3 · Part XII · Create New World: a seed typed in, Superflat chosen, a layer deleted, an experiment switched on, and the settings object that comes out the other end.
You click Create New World and the game stops on Preparing for world creation… for a moment. Then a three-tab screen: a name, a game mode, a seed box, a world-type button, and buttons for game rules, data packs and experiments. You type a seed, cycle the type to Superflat, open Customize and delete the dirt layer, switch on an experiment, set one game rule, and press Create.
That opening pause is what this page is about. Before the screen can draw a
single widget the game has already run a complete server-side data-pack
load — the same WorldLoader.load a dedicated server runs at startup
(the world load) —
mostly on the
worker pool, with the Render thread parked on BlockableEventLoop.managedBlock, running queued tasks, the load’s own among them, while it waits
(the event loop),
until it finishes. The settings the screen exists to edit, a WorldGenSettings, were built halfway through that load, out of registries the load had just filled. Every widget on the World tab is an edit to them — the name, the game mode, the difficulty, Allow Commands and the
game rules go somewhere else entirely, into the LevelSettings and the
GameRules that ride beside it. Two of the buttons throw the whole load away
and run it again whenever they change the pack list. And Create barely touches them: by the time you press the button, the world’s generation settings have existed in memory for as long as the screen has, and Create only packs them into a fresh WorldGenSettings.
Everything else in Part XII reads that object. This page is where it comes from, where it goes, and the dedicated server’s and Re-Create’s routes to one.
The cast
| class | what it owns | its thread |
|---|---|---|
WorldGenSettings | the whole answer: a WorldOptions and a WorldDimensions, and nothing else. It is a SavedData | built on the worker pool in every load, and again on the Render thread by Create; read on the Server thread, and by ChunkStatusTasks.generateStructureStarts on the worldgen executor |
WorldOptions | the seed, generate structures, bonus chest and a legacy string — four fields, all immutable, each with-method returning a new one | — |
WorldDimensions | a map from LevelStem key to LevelStem, each a dimension type plus a ChunkGenerator. Refuses to exist without an overworld | — |
WorldLoader | the data-pack load every path shares, and the seam (WorldLoader.WorldDataSupplier) where the caller decides what the settings are | worker, with hops to the thread that called it |
WorldCreationContext | the settings plus the loaded registries and ReloadableServerResources they were parsed against — the screen’s whole world | Render thread, replaced wholesale |
WorldCreationUiState | the widget-visible state, and the listener list that keeps the tabs agreeing with it — CreateWorldScreen registers seven | Render thread |
WorldOpenFlows | every route from the world list into a running server, each one a chain of methods that can interpose a confirmation | Render thread |
MinecraftServer | takes the finished object out of the WorldStem and hands it to SavedDataStorage, which is what puts it on disk | the hand-off in its constructor, on the caller’s thread — the Render thread for a single-player world; the write at its first save, on the Server thread |
Five stages, and only one of them is the screen
flowchart TD
subgraph WL["mostly the worker pool"]
A["1 · load the packs and the registries"] --> B["2 · the caller's callback decides the settings"]
B --> C["3 · recipes, loot and functions read against them"]
end
C --> D["4 · the screen edits the settings"]
D -->|"the pack list changed"| A
D -->|"Create"| E["5 · bake, write level.dat, start the server"]
Stages one to three are one WorldLoader.load call, mostly off the Render thread, and the screen is stage four — the only stage that can send the whole load back to the start.
The ordering that matters is stage 2 before stage 3, and it is the reason
this page exists at all. WorldLoader.load takes the settings-building
callback as a parameter and calls it in the gap between loading the world and dimension registries and loading the reloadable ones — the recipes, the loot tables and the rest — so the registry set those and the functions are then parsed against includes the dimension registry that callback produced. The seed and the dimension list
are settled before a single recipe is read, and they are settled by a lambda
the caller supplied, which is the only reason the client’s create screen,
the client’s world-opener, the dedicated server and the game-test server can
share one loader.
The registry pass that callback sits behind is
RegistryDataLoader.DIMENSION_REGISTRIES, and it is the last registry pass before the callback — the reloadable registries follow it, inside ReloadableServerResources
(when a world opens).
It holds exactly one registry,
Registries.LEVEL_STEM, loaded in its own pass because its entries need every
worldgen registry already in hand — and that single-entry list is the
dimension/ folder of a data pack.
The object, and what is not in it
WorldGenSettings has two fields. WorldOptions holds the seed, a
generate structures flag, a bonus chest flag and a string that only a very
old save carries. WorldDimensions holds the map of LevelStems. There is no
world name in it, no difficulty, no game mode, no game rule and no data-pack
list — those are LevelSettings and GameRules, and
level data and rules
says which file each of them ends up in.
It does not end up in level.dat either. WorldGenSettings extends
SavedData and carries its own SavedDataType, so it is written to
data/minecraft/world_gen_settings.dat — and the game rules to
data/minecraft/game_rules.dat — by the ordinary saved-data machinery rather
than by the level-data writer. That is a different writer on a different thread
at a different moment, which is the last thing this page traces.
A seed is not a number the box gives you. WorldOptions.parseSeed trims the
text, returns nothing at all for an empty string, parses a long if it can, and
otherwise returns the Java string hash of what you typed — which is why a seed
of glacier is a seed and a seed of 99999999999999999999 is the hash of
that text rather than the number. And nothing at all is not zero:
WorldOptions.withSeed turns an absent seed into WorldOptions.randomSeed,
one draw from a fresh RandomSource. The seed field’s responder calls
WorldCreationUiState.setSeed on every keystroke, so an empty box is
re-rolling a new random world every time you touch it.
Every widget is an edit to a live object
WorldCreationUiState is not a form. It holds the WorldCreationContext itself, rebuilds it whenever a generation setting changes, and on every change of any kind walks a listener list so
that each widget re-reads what it should now show. The state is also opinionated
about what it returns: WorldCreationUiState.getDifficulty reports hard in
hardcore whatever the button last set, WorldCreationUiState.isAllowCommands
reports true in a debug world and false in hardcore, and
WorldCreationUiState.isBonusChest reports false in both. The buttons are
disabled to match, but the state would lie to them anyway.
The world-type button is the destructive one. WorldCreationUiState.setWorldType
calls WorldPreset.createWorldDimensions and replaces all the dimensions
with the preset’s, so leaving Superflat and coming back to it discards whatever the Presets screen chose, but not a layer deleted from the stack the button gave you: that was deleted from the world preset registry’s own object (below) and comes back with it, until a pack change reloads the registry. Seven world presets ship as JSON under
data/minecraft/worldgen/world_preset/, and the button reaches six of them: it
cycles the normal tag, which holds five — Default, Superflat, Large Biomes,
Amplified and Single Biome — and holding Alt swaps it for the extended tag,
which is those five plus debug_all_block_states. The seventh,
WorldPresets.FLAT_ALL_DIMENSIONS, is in neither tag, so the world-type button never cycles to it: on the client only CreateWorldScreen.testWorld selects it, behind a TW button the title screen adds when SharedConstants.IS_RUNNING_IN_IDE, while the game-test server builds every test world from it and a dedicated server’s level-type can name it.
Customize is rarer than it looks. PresetEditor.EDITORS is a two-entry map:
WorldPresets.FLAT opens CreateFlatWorldScreen and
WorldPresets.SINGLE_BIOME_SURFACE opens CreateBuffetWorldScreen, whose one choice becomes a FixedBiomeSource — the source a superflat and a Debug Mode world use too. For the
other five presets the button is inactive. Both editors end the same way, in
WorldCreationContext.DimensionsUpdater lambdas that call
WorldDimensions.replaceOverworldGenerator — the overworld only. Nothing in
the create screen can edit the nether or the end.
The layer editor edits the generator you already have
FlatLevelGeneratorSettings is the odd object among the settings this page handles, where WorldOptions, WorldDimensions and WorldPreset offer nothing to change. Its layer list — of FlatLayerInfo, a block and a height — is
mutable, its lakes and features flags are
set by void methods, and FlatLevelGeneratorSettings.getLayersInfo hands out
the live list. PresetEditor passes CreateFlatWorldScreen the settings of
the current overworld generator when that generator is already a
FlatLevelSource — the same object, not a copy, and, while the stack is the one the world-type button gave you, the world preset registry’s own — and the Remove Layer button
removes an entry from that list directly and calls
FlatLevelGeneratorSettings.updateLayers.
So the Cancel button on the layer editor does not undo a layer deletion.
Cancel only skips the WorldCreationContext.DimensionsUpdater that would build
a new FlatLevelSource; the list it would have been built from has already
changed. The Presets screen is the well-behaved half of the same screen:
PresetFlatWorldScreen reads and writes the layer stack as a text string and
hands back a new settings object through
FlatLevelGeneratorSettings.withBiomeAndLayers. Nine flat presets ship as
JSON, one for each key FlatLevelGeneratorPresets registers a value for; the
tenth key, FlatLevelGeneratorPresets.TEST_WORLD, is declared, never given a
value, and read by nothing in the game.
One thing the flat generator does not do is place all its own blocks.
FlatLevelGeneratorSettings.adjustGenerationSettings walks the built layer
stack and, for every layer whose block fails
Heightmap.Types.MOTION_BLOCKING’s opacity test, replaces it with a null and
re-adds it as an inline FillLayerFeature placed feature in the
TOP_LAYER_MODIFICATION decoration step. That test is the BlockTags.BLOCKS_MOTION_IN_HEIGHTMAP tag or a fluid, so the water in Water World stays terrain; what leaves it are the
air of The Void and the snow layer on top of Snowy Kingdom, which arrive
as features.
An experiment is a data pack, so switching one on reloads everything
ExperimentsScreen looks like a toggle list and is a filtered pack browser: it
walks the repository’s available packs and keeps only those whose
Pack.getPackSource is PackSource.FEATURE — a built-in pack that carries a
feature-flag section and deliberately refuses to be selected automatically
(the repository and its packs).
Three ship —
minecart_improvements, redstone_experiments and trade_rebalance — one per
non-vanilla flag in FeatureFlags, and what a flag then gates is
feature flags. Pressing Done rewrites the repository’s
selection and lands in CreateWorldScreen exactly where the data-pack screen
lands, in CreateWorldScreen.tryApplyNewDataPacks.
That method has a fast path and a slow one. If the enabled-pack list comes back unchanged, WorldCreationUiState.tryUpdateDataConfiguration
swaps the configuration in and nothing reloads. Otherwise
CreateWorldScreen.applyNewPackConfig puts a validating message on screen and
runs WorldLoader.load again from the top — and it has to carry your settings
across a registry set that is about to be replaced. It does that by
serialising them: WorldGenSettings.CODEC encodes the current options and
dimensions to JSON using the old registries as context
(where the registry context comes from),
and re-parses that JSON
against the new ones. Every Holder in the object — every biome, every noise
settings, every structure set the flat generator overrides — is written out as
an id and looked up again. If the new packs have no world preset or no biome,
or the re-parse fails, the future completes exceptionally and the player gets a
retry-or-reset confirmation instead of a screen.
The two routes into that method differ in one boolean, and in the screen a failure returns to.
CreateWorldScreen.tryApplyNewDataPacks shows
ConfirmExperimentalFeaturesScreen only when the requested flags are
experimental and the caller was the data-pack screen. Toggling an experiment
in the Experiments screen skips it — that screen carries a red warning line of
its own instead.
Data packs added here do not go into a world folder that does not exist yet.
CreateWorldScreen.getOrCreateTempDataPackDir makes a temporary directory
prefixed mcworld-, the pack browser is pointed at that, and the directory is
copied into the new world’s datapacks folder by
CreateWorldScreen.createNewWorldDirectory at the very end. It is then deleted
on every exit including that one: CreateWorldScreen.removeTempDataPackDir
runs on the line after the create callback returns.
What Create does
sequenceDiagram
participant CWS as CreateWorldScreen
participant WCUS as WorldCreationUiState
participant WOF as WorldOpenFlows
participant MC as Minecraft
participant IS as IntegratedServer
participant Disk as Disk
rect rgba(0, 0, 0, 0.04)
Note over CWS,Disk: Render thread
CWS->>WCUS: read the context one last time
WCUS-->>CWS: WorldOptions and the selected WorldDimensions
CWS->>CWS: bake the dimensions, and read the lifecycle off them
CWS->>WOF: confirmWorldCreation, with that lifecycle
WOF-->>CWS: proceed, or a warning screen first
CWS->>Disk: the world directory, with the temp datapacks copied in
CWS->>WOF: createLevelFromExistingSettings
WOF->>MC: doWorldLoad
MC->>Disk: level.dat, through a temp file
MC->>IS: new, with the WorldStem and the screen's GameRules
IS->>IS: the constructor hands WorldGenSettings to its SavedDataStorage
end
rect rgba(0, 0, 0, 0.04)
Note over IS,Disk: Server thread, first save
IS->>Disk: world_gen_settings.dat and game_rules.dat
end
The client writes level.dat before the server exists; the settings reach disk later, from the server’s own first save, in two files of their own under data/minecraft.
Three details in that order are worth stopping on. CreateWorldScreen.onCreate
bakes the dimensions to decide the lifecycle and the
PrimaryLevelData.SpecialWorldProperty, but the WorldGenSettings it stores
holds the unbaked selection — the bake is what runs, the selection is what
is saved. The warning is skipped when the world is not a re-create and the baked
registries are stable, and WorldDimensions.checkStability asks that question
per key — anything under a fourth key is experimental by construction — but it asks a different question of each of the three.
WorldDimensions.isStableNether requires the nether’s dimension type, a NoiseBasedChunkGenerator on the vanilla nether settings and a MultiNoiseBiomeSource on the vanilla parameter list, and WorldDimensions.isStableEnd the End’s type, the vanilla End settings and a TheEndBiomeSource. The overworld’s check
tests the dimension type too, and then the parameter list only if the biome source
happens to be a MultiNoiseBiomeSource at all. So Amplified passes on
non-vanilla noise settings, and Superflat and Single Biome pass by not having a
multi-noise biome source to fail on. The upshot is that no preset the button
can reach ever raises this warning: the only shipped one that fails is Flat
All Dimensions, which gives the nether and the end flat generators — and the
world-type button never cycles to it. In practice the warning is a data-pack warning. And
level.dat is written by
the client, in Minecraft.doWorldLoad, before the Server thread exists —
while the settings file is written by the server after it starts, because
MinecraftServer’s constructor is the first thing to hand the object to
SavedDataStorage.
The game rules take a route of their own to the server.
CreateWorldScreen.onCreate copies the screen’s GameRules into an Optional that travels through
CreateWorldCallback, WorldOpenFlows.createLevelFromExistingSettings and
Minecraft.doWorldLoad to the MinecraftServer constructor, which builds a
fresh rule set from the saved-data default and then overlays the screen’s
values on top.
The same object, built two other ways
The screen is one of several programs that produce a WorldGenSettings — the demo world and the developer-only DEBUG recreate button make one through WorldOpenFlows.createFreshLevel, and the game-test server makes one too — and setting it beside the dedicated server and Re-Create is the clearest way to see which parts of this
page are the subject and which are only its interface. The dedicated server
never sees a screen at all; Re-Create sees this one and feeds it an answer.
| client create screen | dedicated server | Re-Create | |
|---|---|---|---|
| who builds it | CreateWorldScreen.onCreate | Main.createNewWorldData | CreateWorldScreen.onCreate again, from what WorldOpenFlows.recreateWorldData read |
| the seed | the seed box, per keystroke | level-seed, in the DedicatedServerProperties constructor | copied from the old world’s settings |
| the dimensions | a WorldPreset plus screen edits | level-type as a world-preset id; the legacy names default and largebiomes parse as ids first, match no preset and give normal | the old world’s saved LevelStem map |
| customising | PresetEditor, overworld only | generator-settings JSON, parsed by FlatLevelGeneratorSettings.CODEC and only when the preset is WorldPresets.FLAT | the create screen again |
| game rules | WorldCreationGameRulesScreen | one, and it is a legacy key: announce-player-achievements sets GameRules.SHOW_ADVANCEMENT_MESSAGES | read back from game_rules.dat |
| when | only if the folder is new | only if there is no level.dat | always a new folder |
Both seed paths are the same method. DedicatedServerProperties calls
WorldOptions.parseSeed on level-seed and falls back to
WorldOptions.randomSeed, exactly as the seed box does — so an empty
level-seed draws its random seed the moment the properties file is parsed,
whether or not a world is about to be created. An unrecognised level-type is
a warning in the log and the normal preset, not a failure.
Re-Create is the interesting column. WorldOpenFlows.recreateWorldData reads
the old world with a deliberately empty LevelStem registry, so the
dimensions come from the saved settings rather than from any pack, and its caller in the world list hands CreateWorldScreen.createFromExisting a LevelSettings and a context. The
result is a new world folder with the old seed pre-filled. Nothing in the
family edits an existing world’s WorldGenSettings in place: EditWorldScreen
offers a rename, an icon reset, a folder button, a backup, the backups folder and Optimize World,
and not one generation setting.
The rest of the family
Two more corners of client/gui/screens/worldselection matter to this page. The rows of SelectWorldScreen’s
WorldSelectionList are LevelSummary objects read by
LevelStorageSource.readLightweightData, an NBT parse that
deliberately skips the Data/Player and Data/WorldGenSettings subtrees — the
second of them named by PrimaryLevelData.OLD_WORLD_GEN_SETTINGS, all that is
left of the old key — so that listing a hundred worlds never costs a settings
parse. And WorldOpenFlows.openWorld is a chain of methods — itself, then level data, version compatibility, the upgrade, the world stem, stem compatibility, a bundled resource pack, disk space, and finally Minecraft.doWorldLoad — nearly every one of which can stop and put a confirmation screen in the way, which is the shape of every route from the world list’s own buttons, and of neither the demo world’s nor a server’s.
For a 1.21-era reader. The seed has left level.dat.
WorldGenSettingsis aSavedDatawith its own file, data/minecraft/world_gen_settings.dat, and the game rules likewise;PrimaryLevelDatakeeps the old key name only as the constantPrimaryLevelData.OLD_WORLD_GEN_SETTINGS.
Where to look
Start with net/minecraft/world/level/levelgen: WorldGenSettings is short and tells you the whole shape, WorldOptions and WorldDimensions are
the two halves, and WorldDimensions.bake is the method that turns a selection
into a registry. Then net/minecraft/server/WorldLoader — one method, and the
spine every path shares. Only then
net/minecraft/client/gui/screens/worldselection, in the order
WorldCreationContext, WorldCreationUiState, CreateWorldScreen and
WorldOpenFlows, with net/minecraft/client/gui/screens/CreateFlatWorldScreen
and net/minecraft/world/level/levelgen/flat beside them. The comparison is
DedicatedServerProperties.createDimensions and Main.createNewWorldData, and the destination is the opening of the MinecraftServer constructor.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
XIII · Commands and data packs
Verified against Minecraft 26.3 · Part XIII · A string typed into a chat box becomes a call with typed arguments, on a queue, with a permission attached — and four whole systems are built on top of that.
Type a slash. The text turns grey and aqua and red as you type, a hint
appears behind the cursor, and pressing Enter sends the string — the
parse the client just did is thrown away. On the server the same string is
parsed again, against a tree whose nodes carry permission requirements, and
becomes a piece of work on a queue rather than a Java call — a heap-allocated
deque of entries that one flat loop drains, which is why pending commands can be deleted part-way through an execution and why a function calling itself
does not blow a Java stack. Everything else in this part rides that
machinery: an advancement is a subscription
delivered by a trigger and edited by /advancement, a scoreboard is a number that /scoreboard and execute store write, as the game’s own criteria do, a dialog is a
data-pack form opened by /dialog, and a game test is a data-pack test run
by /test. None of those four needs any of the others to be understood. What they need from this part is the parse and the queue; before you start names the other parts they lean on most.
The nine packages the atlas
gives this part are three groups. The machinery: net/minecraft/commands
and server/permissions. The catalogue: net/minecraft/server/commands, one file per command and a few helpers beside them. The four systems: net/minecraft/advancements,
world/scores with server/bossevents, server/dialog with its
client/gui/screens/dialog screens, and net/minecraft/gametest. Counted
the way the atlas counts everything, that is
481 files and 44,083 lines — of which the catalogue alone is 111 files and 13,357 lines, most of them a thin lambda over machinery another page owns. So what a command does once dispatched is usually another part’s page, and Brigadier and commands carries an index of the main doors; the statistics, which are criteria, are in what
this book skips.
The shape of the part
Part XIII is a stack of three floors, and for a watcher the dependency
runs one way: all four systems on the top floor need both of the floors
below, and none of them needs another. The code is less tidy than the lecture order: the lower floors reach up into all four top-floor systems — a selector’s advancements= and scores= options, the scoreboard’s own argument types, a dialog’s id, and /test’s registration — and one advancement predicate reads a team, sideways.
flowchart TD
subgraph U["four systems"]
direction LR
L6["6 · Advancements"] ~~~ L7["7 · Scores, teams<br/>and stored data"] ~~~ L8["8 · Dialogs"] ~~~ L9["9 · Game tests"]
end
U -->|"is built on"| X
subgraph X["execute"]
direction LR
L4["4 · The execution engine"] ~~~ L5["5 · Functions and macros"]
end
X -->|"is built on"| P
subgraph P["parse"]
direction LR
L1["1 · Brigadier and commands"] ~~~ L2["2 · Permissions"] ~~~ L3["3 · Entity selectors"]
end
The part as three floors, numbered to the watch order — parsing turns a string into a call, execution turns the call into work on a queue, and the four systems on top each have a command of their own. An arrow runs from a floor to the floor it is built on, the reverse of the prerequisite-first arrows on most other landing figures, so that the top floor is drawn on top, and nothing on the top floor points at another box there.
The four pages on the top floor are peers, not a sequence: watch them in any order, or only the ones you care about. The two floors below them are not optional for any of the four.
Before you start
The server tick from Part III. Two pages in this part turn on where in that order a thing sits — command functions near the top, an advancement’s flush at the end of a player’s own tick — and neither is legible without the order itself.
Codecs, NBT and JSON and the data-driven type pattern from Part II. Dialogs and game tests are the pattern’s clearest two instances — a form and a test suite, both reduced to JSON dispatching on a registry of types — and the pattern page is where the pattern is set out.
The connection from Part IX, for the boundary between the Netty threads and the Server thread, which the command packets and the suggestion request each cross their own way.
Contexts and predicates from Part
VII, if you are here for advancements: a trigger’s conditions are loot
conditions, evaluated against a loot context, and that page owns the
machine — as it owns /execute if predicate, whose test a selector’s own predicate option runs too.
Watch in this order
- Brigadier and commands — three parsers for one string, and a tab-completion whose fast path never leaves the machine. Also: which sixty-six of the four hundred and fifty-four argument-node declarations ask the server by hand, and why they feel like all of them.
- Permissions — a permission is not an integer, an operator does not have everything, and a permission failure is reported as a typo.
- Entity selectors — a selector is a compiled query, and thirteen of its twenty-one options do more than filter: they shape the query plan. Why @p crosses dimensions, why sort=nearest is what takes your limit away, why one permission is checked twice, and which dimension /kill @e[limit=1] picks.
- The execution engine — a command engine with
no Java recursion, and the two game rules that bound one instead. A
fan-out that materialises one player at a time, and a
/returnthat deletes work out of a queue rather than unwinding a stack. - Functions and macros — what a
.mcfunctionfile becomes, in two steps, the second of which usually does nothing. The one that fails silently every tick, forever. - Advancements — the game’s general-purpose “tell me when the player does X”, built as a per-player subscription table that shrinks as criteria are met, and refilled by exactly two things. The tree is laid out on the server and shipped.
- Scores, teams and stored data — one number per
thing, one query language for any tag, a boss bar, and the
execute storeseam that joins all three. Why fake players exist. - Dialogs — a data pack puts a form on your screen, possibly before you are in a world at all. The values are read at the moment of the click and not before.
- Game tests — the game’s own test suite, as a data pack. There is no GameTest annotation, a batch is keyed by an environment and a dimension, the shipped jar contains exactly one test, and a failed one is left standing in the world on purpose.
Where the part stops
21% of the part’s lines are named on no page in the book, over two-fifths of them in the catalogue — itself not quite a third of this part by line — and most of the rest in the advancement triggers and predicates. Nearly all of that is one more instance of something
a page above already draws. The nearly fifty commands nobody names —
TeleportCommand, WorldBorderCommand, WeatherCommand and their neighbours — are each one more row for the
door table on Brigadier and
commands:
a registration and a call into another part. The forty advancement triggers the advancements page does not explain and the concrete predicates beside them are instances of the shapes advancements states and declines to enumerate. The argument types are the same again:
Brigadier and commands
names the families rather than the sixty-two registered types — a type
being a kind of argument and a node one use of one in one command’s tree,
which is why the same page counts 454 declarations of those.
What is genuinely not here is a lecture on the three commands that are
algorithms rather than doors — SpreadPlayersCommand‘s scatter,
CloneCommands’ overlap handling, FillCommand’s replace modes. Each is a self-contained routine that no part’s page is about, and three unrelated
routines do not make one lecture; they are named here so that a reader knows
the omission is deliberate.
Reference this part uses
Packets for this part’s own traffic. The two systems that only report have no serverbound packet at all between them — the scoreboard’s five and the boss bar’s one run one way — while everything a player does comes back: the command and its suggestions, a dialog’s click, an advancement tab, a command block’s settings from the block or the minecart, and the two the test blocks’ edit screens send. Registries and the data-driven type pattern for the six type registries dialogs and tests dispatch on, and loot context parameter sets for the sets an advancement trigger and its reward run in. Diagram lanes for the abbreviations these figures use, and the glossary for Brigadier, selector head, world-limited, criterion, objective, macro, boss bar, dialog and game test.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Brigadier and commands
Verified against Minecraft 26.3 · Part XIII · You type
/give @p diamond_sword[minecraft:damage=5]into the chat box: three parsers see that string, two of them throw their answer away — and not one of the completions you accepted along the way left your machine.
Open the chat box and type a slash. Before you have finished the word, the text is coloured, a grey hint has appeared behind the cursor and a completion popup is open — and every bit of that was produced by a real Brigadier dispatcher running on your own machine, with real parsers built against your own registries, from a tree the server sent you when you joined. The client is not pattern-matching strings. It parses the whole line on every keystroke, throws the parse away, and sends the string.
Which raises the question this page exists to answer: if the client can parse the command, why is completing an item id instant and completing a loot table not? Because a loot table is data the client was never sent. The tree the client rebuilt says, node by node, which nodes ask the server, and an argument over a registry the client lacks asks it too. Vanilla’s command code declares 454 argument nodes, and only 66 of them serialise as ask the server — so the local path is the rule, not the exception. What makes the round trip feel ubiquitous is which completions take it: they are mostly the ones over data the client was never sent.
The cast
| class | what it decides | side |
|---|---|---|
Commands | the one server-side CommandDispatcher, every registration, and what a failed parse is called | server |
CommandSourceStack | who is running this, from where — position, rotation, level, entity, permissions, output sink. Immutable; a change returns a copy | server |
CommandBuildContext | the registries an argument type parses against, so a data pack’s biome is completable with no code change | both |
ArgumentTypeInfos | the wire description of an argument type: a ArgumentTypeInfo.Template that can be written to a buffer and instantiated on the far side | both |
SuggestionProviders | the four providers that have a name on the wire — ask_server, available_sounds, summonable_entities and post_effects. Everything else serialises as ask_server too | both |
SharedSuggestionProvider | the interface both sides’ sources implement, so one argument type can complete on either. It extends PermissionSetSupplier, and its static SharedSuggestionProvider.suggest family is where most completion happens | both |
ClientSuggestionProvider | the client’s implementation of it: the tab list, the looked-at block and entity, and the one method that sends a packet | client |
CommandSuggestions | the widget over it — the highlighter, the usage hint, the popup and the parse cache | client |
BrigadierExceptions | installed once into Brigadier’s global exception provider, which is why a parse error is a translatable Component | both |
Brigadier itself — the dispatcher, the tree of literal and argument nodes, each with a requirement predicate and optionally executable — is Mojang’s parsing library and lives outside the game’s packages. Everything on this page is what Minecraft builds on top of it.
Three parsers see one string
sequenceDiagram
box Client
participant CSug as Command<br/>Suggestions
participant CPL as ClientPacketListener
participant CSP as ClientSuggestion<br/>Provider
end
box Server
participant SGPL as ServerGamePacket<br/>ListenerImpl
participant Cmds as Commands
participant GC as GiveCommand
end
CSug->>CPL: getCommands — the client's own dispatcher
CSug->>CSug: updateCommandInfo — parse 1, every keystroke
opt a node, or a registry the client lacks, asks the server
CSug->>CSP: customSuggestion
CSP->>SGPL: ServerboundCommand<br/>SuggestionPacket
SGPL-->>CPL: ClientboundCommand<br/>SuggestionsPacket
CPL->>CSP: completeCustomSuggestions — a stale id is dropped
end
CPL->>CPL: sendCommand, on Enter — parse 2, for signatures only
CPL->>SGPL: ServerboundChatCommandPacket — the string
SGPL->>SGPL: tryHandleChat — illegal characters, on the Netty thread
rect rgba(0, 0, 0, 0.04)
Note over SGPL,GC: the server thread
SGPL->>SGPL: parseCommand — parse 3, the one that is used
SGPL->>Cmds: performCommand
Cmds->>GC: the registered lambda
GC->>GC: giveItem — Inventory.add, then sendSuccess
end
Three parses of one /give — two on the client whose answers are thrown away, and the server’s, the only one that runs; the round trip in the frame is taken only when a node asks the server or an argument needs data the client lacks, and /give has neither.
The three parsers of the title are in there: the client’s, running on every keystroke; the client’s again, once, when you press Enter; and the server’s, which is the only one whose answer is used. Each arrow below is a decision.
The client parses first, and the parse never leaves the machine.
CommandSuggestions.updateCommandInfo runs the whole string through the
client’s dispatcher on every keystroke — the one
ClientPacketListener.getCommands hands it, rebuilt from the tree the server
sent — and that parse produces the red tail, Brigadier’s smart-usage hint and the completion list. What is
sent is the string.
Item, block-state and component completion never leaves the machine
either. ItemArgument and BlockStateArgument are registered as
context-aware singletons — meaning the wire form carries no data of its
own and the far side builds the parser against its own
CommandBuildContext, which is The tree on the wire below — so the client holds a real ItemParser and builds a real BlockStateParser against its own registries. Item ids, data
components and block properties complete locally, and so does every argument
type whose suggestion method reads a synced registry.
A node that asks for suggestions by hand almost always asks the server.
This is the part the shape of the code invites you to get backwards — not
that the round trip is common, but that opting in to a provider is what
costs you one. SuggestionProviders.getName returns the registered name for
a SuggestionProviders.RegisteredSuggestion and ask_server for
everything else, so any node whose suggestions come from a plain lambda
serialises as a request. Of the 73 vanilla nodes that attach a provider at all, seven name one of the three providers that mean anything on the wire — three available_sounds, two summonable_entities and two post_effects — and the other 66 become ask_server. That is how /function, /datapack, /bossbar,
/scoreboard, /team, /schedule and /whitelist complete. The other 381 argument nodes attach nothing and fall back to their argument type’s own
suggestions — which is why /give, the command in the line at the top of
this page, never asks the server anything. A second route reaches the same packet:
ClientSuggestionProvider.suggestRegistryElements failing to find a
server-only registry — loot tables, advancements, recipes — and falling
through. Two mechanisms, one packet.
One interface is why an argument type never knows which side it is on.
SharedSuggestionProvider is implemented by CommandSourceStack on the
server and by ClientSuggestionProvider on the client, and it is what an argument type’s suggestions ask of the source, when they ask it anything, so the same code runs on either. Most of it is static helpers — the SharedSuggestionProvider.suggest
and SharedSuggestionProvider.suggestResource families, and the coordinate
suggesters that offer you the block you are looking at — and all but one of its instance methods are the questions the two sides answer differently, among them the tab list, the selected entities, the relevant coordinates, and
SharedSuggestionProvider.customSuggestion, which is the packet on the client
and a completed empty future on the server.
Replies are matched by id, so a stale answer never flashes. The reply,
ClientboundCommandSuggestionsPacket, lands on
ClientPacketListener.handleCommandSuggestions, which forwards it to the
provider. ClientSuggestionProvider.customSuggestion cancels the in-flight future and
increments a counter;
ClientSuggestionProvider.completeCustomSuggestions compares the reply’s id
against that counter and drops anything older. The reply itself is
truncated to a thousand entries, silently — no marker, no message.
Enter parses a second time on the client, for one reason.
ClientPacketListener.sendCommand, which ChatScreen calls, runs SignableCommand.of to find out
whether any argument is a SignedArgument. /give has none, so the plain
packet — ServerboundChatCommandPacket, the string alone — goes; a /msg would take a timestamp, a salt and the last-seen
message set, sign each signable argument and send the signed variant. A
command the player did not type — a dialog button, a chat click event —
goes through ClientPacketListener.sendUnattendedCommand instead and is parsed once or twice more before anything is sent
(permissions).
A sign is the exception that proves the rule: its click command reaches the client only as the sign’s text, and is never checked there, because the server runs it when the sign is used, and only if the sign’s allow_op_features flag is set.
The inbound packets cross the thread boundary differently, and neither takes the ordinary hop. ServerboundCommandSuggestionPacket is handed, on the Netty thread, to a ServerCommandSuggestionsProvider, which keeps only the newest request and answers at most one a tick, parsing it on the Server thread; the command packets do real work on the Netty thread first: ServerGamePacketListenerImpl.tryHandleChat refuses illegal characters there and only then hands the rest to the Server thread.
The hop the other packets take is
the connection
(the server
tick
counts them, and chat and
signing says what each
of those checks catches). What matters for a command is only that the
validation which can disconnect you runs before the parse does — so a command
whose text is illegal never reaches the dispatcher at all.
The authoritative parse is the server’s —
ServerGamePacketListenerImpl.parseCommand — with a CommandSourceStack
from ServerPlayer.createCommandSourceStack carrying the real permission
set, and Brigadier consults each node’s requirement during that parse.
Execution then is not a Java call: Commands.performCommand flattens the
parse into a context chain and hands it to
Commands.executeCommandInContext, which is
the execution engine.
/give itself is unremarkable and instructive. Its lambda calls
GiveCommand.giveItem: EntityArgument.getPlayers resolves the selector, ItemArgument.getItem
hands over the ItemInput that was built during parsing,
ItemInput.createItemStack validates it, and the stacks go through
Inventory.add with anything that will not fit dropped on the floor
(items and stacks). Success goes, through CommandResponseTracker.sendFeedback, to CommandSourceStack.sendSuccess — which takes a supplier, so the message
is never built when nobody will see it — and broadcasts to admins under two
game rules.
Where a command’s output goes
CommandSourceStack carries a CommandSource — a message sink, three questions and a fourth question with a default, and no state — and its implementations are the whole answer to why a command block does not spam chat. MinecraftServer (the console) and RconConsoleSource accept
both success and failure, and RconConsoleSource.shouldInformAdmins defers to
the broadcast-rcon-to-ops property. BaseCommandBlock’s source accepts
success only under GameRules.SEND_COMMAND_FEEDBACK and informs admins only
under GameRules.COMMAND_BLOCK_OUTPUT, which is what the second of those game rules is for; a command block with Track Output off gets no source of its own. Each player has one, and so does /debug function’s tracer.
CommandSource.NULL refuses everything, and the game hands it to several sources with nobody to talk to — a sign running its own click command, a text component being resolved, that command block. The command block is CommandBlock, whose own redstone edge, tick and chain walk decide when it runs, and the CommandBlockEntity that holds a BaseCommandBlock: a stored string and a source built for each run.
Arguments that are recipes, not values
Three argument families do not produce a value at all. They produce
something evaluated against the CommandSourceStack at run time, which is
why one parsed command means different things at different links of an
/execute chain.
Coordinates holds relativity, not a position.
WorldCoordinates is the implementation — three WorldCoordinates, each a
value plus a is this relative flag — and
Coordinates.getPosition resolves it against the source, so ~ means
something different at every link and a single parsed argument yields N
positions in a forked execution. LocalCoordinates (^ ^ ^) is the
interesting one: it builds a basis from the source’s rotation and its
EntityAnchorArgument.Anchor, so it is the only Coordinates shape that
depends on eye height — it is not itself an argument type, and both
Vec3Argument and BlockPosArgument can produce one. SwizzleArgument
parses an axis subset (xz), and /execute align is its only user.
EntitySelector is a compiled query, not a parse tree — thirteen final fields with no grammar in them, assembled by
EntitySelectorParser from EntitySelectorOptions and resolved against a
CommandSourceStack much later, which is why one parsed selector yields a
different set at every link of a chain. ScoreHolderArgument and
GameProfileArgument reimplement the same selector-or-literal fork for their
own value types. The grammar, the twenty-one options, the two-phase
permission check and the two data structures a selector can be resolved
against are entity selectors.
FunctionArgument reads an id and nothing else, deferring the lookup to
execution — which is what lets a function be compiled against a null server
(functions and macros).
The parser under the parser
Five argument types and the whole SNBT reader are not hand-written
StringReader walks. They are grammars, written against
net/minecraft/util/parsing/packrat — Mojang’s own parser-combinator
framework, with Term as the combinator algebra, Dictionary and
NamedRule binding named productions, Scope as the typed capture
environment, and CachedParseState as the memo table keyed by position and
rule. That memo table is the packrat in the name: a backtracking grammar
that would otherwise re-parse the same prefix once per alternative looks it
up instead.
The reason it matters to a command page is ErrorCollector and
SuggestionSupplier: the grammar produces completions as a by-product of
failing. A hand-written argument type can only suggest at a token boundary
it thought to check; a grammar knows every terminal that could have
continued the parse, which is why /clear @s minecraft:diamond_sword[…
still completes mid-token. The consumers are exactly nine —
ComponentArgument, NbtTagArgument, ResourceOrIdArgument,
StyleArgument, ItemPredicateArgument, ComponentPredicateParser, and
TagParser / SnbtGrammar / SnbtOperations on the NBT side
(codecs, NBT and JSON).
The tree on the wire
What the packet carries
Commands.sendCommands makes a deep copy of the dispatcher’s tree, filtered
by each node’s requirement for that player’s source
(Commands.fillUsableCommands), and serialises it. An argument node carries
the registry id of its ArgumentTypeInfo plus whatever the template writes:
usually nothing (SingletonArgumentInfo writes zero bytes), often a flags
byte from ArgumentUtils.createNumberFlags or EntityArgument’s
single / players-only pair, sometimes a registry key. The client then
builds real parsers from those templates against its own
CommandBuildContext, which is why a data pack’s biomes and dialogs are
parseable on the client for free.
Six argument types for one idea
One family is worth naming because a reader meets it constantly and never
under one name. ResourceArgument, ResourceKeyArgument,
ResourceOrIdArgument, ResourceOrTagArgument, ResourceOrTagKeyArgument
and ResourceSelectorArgument are six argument types over the same idea — an id resolved against a registry, most of them through the CommandBuildContext — differing
in what they hand back (a Holder, a bare ResourceKey, an inline literal,
a tag as well as an element) and in whether they accept a glob. Only
ResourceSelectorArgument does, which is why /test run * works and
/give @s * does not. Beneath them, the five classes of
commands/synchronization/brigadier are the wire descriptions of Brigadier’s
own primitive types — StringArgumentSerializer and the four ArgumentTypeInfos for integer, long, float and double; with the boolean, which needs no class of its own, they are the only argument types in the game the game did not write.
What is registered, and what is on the wire
Commands.validate is what keeps that honest — though only in development:
Bootstrap calls it under SharedConstants.IS_RUNNING_IN_IDE alone, so a
shipped client never runs it. It throws if any registered argument type is
missing from ArgumentTypeInfos. The two numbers a reader will want do not
count the same thing: forty argument-type classes sit directly in net/minecraft/commands/arguments, with more in its blocks, item,
coordinates and selector subpackages, while sixty-two entries are registered in ArgumentTypeInfos — a class may register several: the six resource types above are twelve entries, ResourceOrIdArgument registering seven through its nested subclasses.
Two surprises, and what /reload does not send
Two things about ClientboundCommandsPacket surprise people. It has exactly one call
site, PlayerList.sendPlayerPermissionLevel, so the tree and the op-level
entity event are always sent together — on join, respawn, a dimension
change, op and deop, and the singleplayer toggles for cheats and guest command access, and not after /reload.
And an unknown argument type deletes the node and everything under it: a
modded type reaching a vanilla client decodes to a null stub,
ClientboundCommandsPacket.NodeResolver substitutes a bare
RootCommandNode, the children are resolved and attached to that throwaway
root, and the parent then skips any child that is a RootCommandNode — so
the node and everything under it vanish from the tree the player can see.
The packet is not rejected, as long as the unknown type wrote no bytes of its own and its node names no suggestion provider. What the filtering means, and the second
elision that rides the same packet, is permissions.
One more thing crosses the wire from here and belongs to nobody else:
ClientboundCustomChatCompletionsPacket, a server pushing arbitrary completions for ordinary chat messages into the chat box, add / remove / set.
/reload builds a whole new Commands and a whole new dispatcher inside
ReloadableServerResources and tells nobody. The consequence is narrower
than the folklore on either side: both server-side parses read through
MinecraftServer.getCommands and pick up the new dispatcher immediately, so
a newly added function does tab-complete after a reload — that completion
is an ask_server round trip. What goes stale on the client is the tree’s
shape and its flags, which no vanilla data pack can change — and the tree is
not re-sent, because its only sender is the permission-level call above
(the resource system
owns the rest of what /reload does).
Commands that are a door to somewhere else
Most of net/minecraft/server/commands — the atlas has its size — is a thin lambda over machinery another page of this book owns. A
reader looking for “how does /locate work” wants the mechanism page, so
here is the index.
| command | what it really reaches | where that lives |
|---|---|---|
LocateCommand | three barely related parts: LocateCommand.locateStructure can drive world generation on the server thread, because deciding whether a structure is at a chunk means asking the structure check; LocateCommand.locateBiome asks the biome source and never reads a stored palette; LocateCommand.locatePoi asks the POI index | structure placement, biomes |
FillBiomeCommand | writes the biome palette of the affected sections and resends them — the only command that edits a chunk’s biomes | chunk anatomy |
PlaceCommand | four doors: a feature with no placement layer, by id or inline, a whole structure, jigsaw assembly directly, and a structure template with rotation, mirror, integrity and a seed | features and placement, jigsaw and templates |
LootCommand, ItemCommands | ItemCommands.applyModifier runs a loot function over an existing stack — /item modify, and the from … <modifier> forms of /item replace, fill and override. A slot is named in the numbering player anatomy decodes, and /item also takes a range of them or a slot source by id or inline. Both take a table or modifier through ResourceOrIdArgument, so an inline literal works where an id does | contexts and predicates for which set each runs in, loot tables for the functions themselves |
EnchantCommand, ExperienceCommand | thin faces over two systems | enchanting, hunger and experience |
ExecuteCommand, FunctionCommand | not commands so much as the front end of the engine | the execution engine, and functions and macros for what /function hands it |
ScoreboardCommand, TeamCommand, TriggerCommand, DataCommands | the commands that write the scoreboard and stored NBT, beside execute store | scores, teams and stored data |
And one class of command a reader will look for in a shipped game and not
find. Commands registers DebugConfigCommand, RaidCommand,
DebugPathCommand, DebugMobSpawningCommand, WardenSpawnTrackerCommand,
SpawnArmorTrimsCommand and ServerPackCommand only when
SharedConstants.DEBUG_DEV_COMMANDS or SharedConstants.IS_RUNNING_IN_IDE
is set, and ChaseCommand behind a flag of its own. DebugConfigCommand is
additionally registered only in a dedicated server’s command set, which matters: it is the only vanilla
caller of the play-to-configuration transition and back
(protocol phases).
Signed arguments, in one paragraph
MessageArgument is the only signed argument in the game — the sole
implementor of SignedArgument — and seven command classes register it
under ten literals a player can type: /ban-ip, /ban, /me, /kick,
/say, /msg with its /tell and /w redirects, and /teammsg with
/tm. All of
SignableCommand, ArgumentSignatures, ArgumentVisitor and
CommandSigningContext exists to serve them: ArgumentVisitor.visitArguments
walks a parse to find which arguments need a signature, and the map carries
them afterwards. A command with signable arguments sent unsigned is
refused outright when the server enforces secure profiles, and a signature
that does not match the parse breaks the player’s whole message chain
(chat and signing).
Where to look
Commands for what exists and what a failed parse is called;
CommandSourceStack for what a command knows; ArgumentTypeInfos for the
catalogue of argument types and their wire forms; EntitySelectorOptions for the grammar players write; CommandSuggestions for everything
that happens while you type; and ClientboundCommandsPacket for the one
place the two sides agree on a shape.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Permissions
Verified against Minecraft 26.3 · Part XIII · You are a level-four operator, you type
/msg, and the server’s own answer to may this player send a chat command is no — because an operator’s permission set grants exactly one thing that is not a command level.
Op yourself to four, the highest rung there is, and ask the server whether
you hold Permissions.CHAT_SEND_COMMANDS — one of five named capabilities
the class Permissions holds, each of them a Permission.Atom. It says no.
Not because you are restricted — because the set you were given is a
LevelBasedPermissionSet, and its answer to any permission that is not a
command level is false, with one hard-coded exception. The chat atoms
live only in the client’s set, which the server never sees and never
sends. There are two permission universes in this game, and they overlap only where the client runs the server’s checks, its rungs and its entity-selector atom, against its copy of your op level.
That is the shape of the model, and the thing to unlearn: a permission is not an integer. The five levels are there, numbered nought to four and stored as integers in ops.json, but a command node does not ask for a number. It asks a
PermissionSet a question, and a set is free to answer however it likes.
The cast
| class | what it decides | where it lives |
|---|---|---|
Permission | what is being asked for: a Permission.Atom (a named capability with an Identifier) or a Permission.HasCommandLevel (a rung) | both jars |
Permissions | nine permissions as constants — four rungs and five atoms; the client mints one more of its own. The plural class is the catalogue; the singular one is the shape | both |
PermissionLevel | the five rungs — all, moderators, gamemasters, admins, owners — and PermissionLevel.isEqualOrHigherThan | both |
PermissionSet | the answer. An interface with one abstract method, PermissionSet.hasPermission | both |
LevelBasedPermissionSet | the ordinary answer: an interface with five constants, one rung each | both |
PermissionSetUnion | the OR of several sets, and the rule that a union may not contain a union | both |
PermissionCheck | what a node demands: PermissionCheck.Require or PermissionCheck.AlwaysPass | both |
PermissionProviderCheck | the Predicate Brigadier holds, over anything implementing PermissionSetSupplier | both |
ChatAbilities | holds the client’s own set, built by subtraction from local reasons | client only |
net/minecraft/server/permissions holds every row above but ChatAbilities, which is the client’s and sits with the client’s chat code: a small package that changes how everything above it is written.
A question, an answer, and a check
Three types do three jobs that a single integer used to do, and keeping them apart is what makes the rest legible.
classDiagram
class PermissionProviderCheck {
PermissionCheck test
}
class PermissionCheck {
<<interface>>
boolean check(PermissionSet)
}
class Require["PermissionCheck.Require"] {
Permission permission
}
class AlwaysPass["PermissionCheck.AlwaysPass"]
class PermissionSet {
<<interface>>
boolean hasPermission(Permission)
}
class Permission {
<<interface>>
}
class Atom["Permission.Atom"] {
Identifier id
}
class HasCommandLevel["Permission.HasCommandLevel"] {
PermissionLevel level
}
class LevelBasedPermissionSet {
<<interface>>
}
class ChatAbilities
PermissionProviderCheck --> PermissionCheck : the check
PermissionCheck <|.. Require
PermissionCheck <|.. AlwaysPass
Require ..> PermissionSet : asks the source's set
Require --> Permission : the question
Permission <|.. Atom
Permission <|.. HasCommandLevel
PermissionSet <|-- LevelBasedPermissionSet
PermissionSet <|.. PermissionSetUnion
ChatAbilities --> PermissionSet : holds one
The three roles as types — the check a node holds at the top, the question it asks, and the set that answers; a hollow arrowhead reads as “is a kind of”, a solid arrow as “holds”, and the one dotted arrow without a hollow head is the single call that joins them.
Permissions holds nine, and the split is four to five:
four Permission.HasCommandLevel constants — one per rung above zero — and
five Permission.Atoms, of which one is the entity-selector gate and the
other four are the chat abilities below.
The question is data: both shapes are records, both are codec-dispatched
over BuiltInRegistries.PERMISSION_TYPE, and Permission.CODEC accepts an
atom written as a bare identifier as well as the full dispatched form. The answer is behaviour: PermissionSet has one abstract method, so every set in the game above is a lambda or a small object, and there is no set-of-permissions data structure anywhere but Permissions.CHAT_PERMISSIONS and the client’s chat code that copies it into ChatAbilities. The check is what
a command node carries: Commands.hasPermission wraps a PermissionCheck
in a PermissionProviderCheck, and that predicate is what Brigadier
consults.
Ninety-seven call sites of Commands.hasPermission, which is every requirement predicate on every command node in the game. Ninety-six are server-side
command registrations; the ninety-seventh is on the client, in
ClientPacketListener’s node builder.
Two things about LevelBasedPermissionSet decide most of this page. It is
an interface with five constants, not a class carrying a level, so
LevelBasedPermissionSet.GAMEMASTER is a singleton and comparing two sets
is comparing two references. And its LevelBasedPermissionSet.hasPermission
tests a Permission.HasCommandLevel against its own rung, grants
Permissions.COMMANDS_ENTITY_SELECTORS from gamemaster upward as a special
case written into the method, and returns false to every other atom.
An operator does not have everything; an operator has a number and one
exception.
Union is where that bites, and the rule is what its name says:
PermissionSet.union falls through to PermissionSetUnion, which ORs its
operands, so a set holding an atom and a set holding a rung together satisfy
both. The exception is the case the server uses.
LevelBasedPermissionSet.union of two level-based sets never builds a
PermissionSetUnion at all, and it does not take the higher of the two: both
branches of the override return the lower-levelled set, so it is a
minimum. CommandSourceStack.withMaximumPermission is that union, which for these sets takes the minimum and so works as the ceiling its name promises — which is how a function body is held down to gamemaster however high the caller sits
(functions and
macros).
Where a set comes from
MinecraftServer.getProfilePermissions is the whole of it — in
singleplayer through its override, IntegratedServer.getProfilePermissions
— and it returns a LevelBasedPermissionSet, never a union, never an atom
set. It is consulted afresh every time ServerPlayer.permissions is called;
nothing is cached on the player.
Its cascade is short. Not on the operator list at all, and you get
LevelBasedPermissionSet.ALL — rung zero, and marked deprecated where it is declared. On the
list, and the entry’s own set wins — ServerOpListEntry holds a
LevelBasedPermissionSet, built when the file was read from the integer the file stores, which for an entry /op wrote on a dedicated server is the op-permission-level property as it stood then (the management API’s operator methods can write any level). Singleplayer’s override answers
first. The owner gets LevelBasedPermissionSet.OWNER when the world allows
cheats and rung zero when it does not; any other player gets
LevelBasedPermissionSet.GAMEMASTER when the world allows cheats and the
host has switched on Command Access for other players, rung zero when
cheats are on without it, and the cascade above when cheats are off.
The integer survives at every edge of that model, which is worth knowing
before you go looking for a permissions file. ops.json stores a number.
server.properties stores a number. The op level reaches the client as one
of five values on ClientboundEntityEventPacket. And
PermissionLevel.byId clamps rather than failing, so an ops.json
hand-edited to level 9 is an owner and level −1 is rung zero. What does not
exist anywhere is a data pack that grants a permission:
PermissionCheck.CODEC has exactly one consumer in the whole game,
ArgumentUtils.serializeNodeToJson writing the generated command report,
and both type registries are bootstrapped in code by PermissionTypes and
PermissionCheckTypes.
The requirement is consulted inside the parse
This is the design decision a server administrator feels most often, and it
is not a message-formatting choice. Brigadier evaluates a node’s requirement
predicate while walking the tree, so a node you may not use is a node that
is not there. Commands.getParseException then reports an empty parse range
as an unknown command and a non-empty one as an unknown argument.
An unopped player typing /give is told there is no such command. The
server cannot tell them otherwise without a second parse, and it does not
do one.
One permission is not settled by the parse alone.
Permissions.COMMANDS_ENTITY_SELECTORS is tested at parse time and again
when the selector is resolved against the world, because a parsed selector
outlives its parse — which is exactly what an /execute chain relies on, and
why entity selectors
owns the two checks and the route that reaches only the second.
Two more consequences worth naming. Commands.LEVEL_MODERATORS gates
nothing: the rung exists, is settable and is stored, and no vanilla
command asks for it. And the ninety-seven gates divide four ways. Ninety-three
name a level constant outright — sixty-eight gamemaster, sixteen admin, nine
owner. Two are the ternary in SeedCommand and VersionCommand, which drop
to Commands.LEVEL_ALL when the server is the integrated one, and those are
the only two appearances of that constant in the game. The last two name a
PermissionCheck rather than a rung: GameModeCommand.PERMISSION_CHECK,
which the end of this page is about, and the client’s own synthetic
restricted-command check.
What the client is allowed to believe
The client has permissions of its own, and they are not a copy of the
server’s — no packet carries a PermissionSet. It has four sources of
belief, and they behave differently enough to be worth separating. Three of
them the server sent or the machine decided; the fourth is the server’s own constants, which the two sides literally share.
The op level, which arrives on an entity event and is mapped by
LocalPlayer.handleEntityEvent onto one of the five sets — on an
integrated server too, where the host’s own LocalPlayer learns its level
the same way. Note the bottom
rung: level zero maps to PermissionSet.NO_PERMISSIONS, not to
LevelBasedPermissionSet.ALL. The two are indistinguishable in practice —
rung zero satisfies no level and no atom either — but the client’s copy is a
different object from the server’s.
The command tree, whose nodes were filtered for this player before being
sent (Brigadier and
commands for what the packet
carries). Two elisions ride that packet and they mean different things.
A node whose requirement the player failed is simply absent, and the
filter is recursive, so a gated literal takes its whole subtree with it. A
node that a no-permission source would fail is flagged
(ClientboundCommandsPacket.FLAG_RESTRICTED) — “this needs some
permission”, asserted independently of you. Commands computes that flag
against a source it builds once from PermissionSet.NO_PERMISSIONS, and
only for nodes that already survived your own filter.
Its own atoms. ClientPacketListener mints a single synthetic
Permission.Atom for restricted commands and keeps two sources over its one
dispatcher: the ordinary one, whose set is the player’s own OR-ed with
that atom, so restricted nodes still highlight and complete; and a
no-permission one. ChatAbilities holds the other client-only set, and it is built the opposite way round from everything else here — it starts from all
four chat atoms granted and lets each ChatRestriction remove some. The
four are what this client will do at all: send messages, send commands,
accept messages from other players, and accept system messages; a client with
the first taken away refuses before the server is ever asked (chat and
signing). Three of the four ChatRestrictions that can remove them are decisions the machine you are sitting at makes — two settings of the chat option and a launcher flag — and the fourth, ChatRestriction.DISABLED_BY_PROFILE, comes
from the account service: a user flag fetched with your profile. The
client’s chat permissions are never granted by the game server; they are
only ever taken away, and only ever from outside it.
The server’s own constants, read locally. The client runs server
permission checks against its own set in several places —
WorldOptionsScreen gates Allow Cheats in a hardcore world on Permissions.COMMANDS_OWNER and its game-rule, game-mode and difficulty buttons on Permissions.COMMANDS_GAMEMASTER, and KeyboardHandler gates two debug keys and reads a third — and almost all of those are the
client asking its own copy a question the server will ask again later. One
is shared outright. GameModeCommand.PERMISSION_CHECK, a
PermissionCheck.Require for gamemaster exported from the command class, is
read by its own command registration, twice by KeyboardHandler — the second is why F3+F4 refuses to open the switcher at all, with debug.gamemodes.error, rather than opening a greyed-out one — once by GameModeSwitcherScreen against LocalPlayer’s own set when it sends the switch, and once more by ServerGamePacketListenerImpl when the packet arrives. One constant, five references in four classes, two sides of the network: the one PermissionCheck both sides read; the other permissions they share are the rungs and the entity-selector atom.
Asking a question the client cannot answer
Put those two client sources together and you get the one thing the client
can diagnose: it can tell “you would need permission for this” apart from
“that is a typo”, for a command it was asked to send on your behalf. A
dialog button and a chat click event both route through
ClientPacketListener.sendUnattendedCommand, whose two callers are
Screen.clickCommandAction and an adapter ClientPacketListener builds for
itself. A sign does not. A sign’s click command reaches every client that loads the sign, as part of its text, but no client runs it: SignBlockEntity runs it on the server when the sign is used, and only when the sign’s allow_op_features flag is set,
through a CommandSourceStack it builds itself at a
hard-coded LevelBasedPermissionSet.GAMEMASTER. There is nothing for the client to vet, because the client never sends it. The
figure is the route the other two take.
flowchart TD
IN["an unattended command — a dialog button or a chat click"]
IN --> P1{"parses with your set, restricted atom added?"}
P1 -- no --> E1["PARSE_ERRORS"]
P1 -- yes --> P2{"any signable argument?"}
P2 -- yes --> E2["SIGNATURE_REQUIRED"]
P2 -- no --> P3{"parses with NO_PERMISSIONS too?"}
P3 -- no --> E3["PERMISSIONS_REQUIRED"]
P3 -- yes --> OK["NO_ISSUES — sent, no screen"]
E1 --> RUN["ConfirmScreen — its button sends it"]
E3 --> RUN
E2 --> SUG["ConfirmScreen — its button puts it in the chat box, or copies it"]
ClientPacketListener.verifyCommand’s three tests in their order, and where each answer leads — only the clean command leaves with no screen, and the signed one is never sent from here at all.
ClientPacketListener.verifyCommand parses the same string against both
sources and reads the difference. Succeeding with the ordinary source and failing with the no-permission one means some node on the path was gated, or an argument used a selector — which is as much as the
client can ever know, because it was never told which permission or whose.
Between the two parses sits a test that has nothing to do with permissions:
SignableCommand.hasSignableArguments. An unattended command cannot be
signed, so one that needs a signature is refused outright — its
ConfirmScreen offers to put the command in the chat box for you to send yourself,
or, when a screen is to follow or chat commands are off, to copy it.
Three of the four outcomes pop a confirmation screen; the fourth,
ClientPacketListener.CommandCheckResult NO_ISSUES, sends with no screen at all. So an unattended command that is
merely unusual is always shown to you first — but a clean one goes
straight out, and a sign’s command never came this way to begin with.
Which answers the operator at the top of this page. Their /msg goes
through, and it goes through because nothing on the server’s side of the
wire ever asks for Permissions.CHAT_SEND_COMMANDS: the /msg node carries
no requirement at all, the atom lives only in this client’s ChatAbilities,
and the one participant that consults it is the machine the player is
sitting at. The server’s no is real and it is never asked. That is what two permission universes buy — and where they overlap, at the rungs and the selector atom, is The server’s own constants, read locally above.
For a 1.21-era reader. PlayerList.setAllowCommandsForAllPlayers is gone: singleplayer answers through its own override,
IntegratedServer.getProfilePermissions, which gives a guest gamemaster behind the host’sIntegratedServer.setGuestCommandAccesstoggle. And the host’sLocalPlayeris no longer set directly byIntegratedServer.publishServer: it learns its level from the entity event like any other player.
Where to look
PermissionSet first — seventeen lines, and the whole model is in them.
Then LevelBasedPermissionSet for the two special cases that decide
everything, Permissions for its nine, and Commands.hasPermission for the one idiom every requirement in a command registration uses.
MinecraftServer.getProfilePermissions for where a player’s set is
decided, and ClientPacketListener.verifyCommand for the only place the client reasons about a permission it was never told about.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Entity selectors
Verified against Minecraft 26.3 · Part XIII · you type /kill @e[type=!player,distance=..8,sort=nearest,limit=1], and the characters inside the brackets decide, between them, which levels are searched and which of two entirely different data structures answers.
Put a command block at the Overworld origin and give it /tp @p 0 100 0. One player is two hundred blocks away across the Overworld; another is standing on the Nether roof directly “above” the block, at the Nether’s own 0, 128, 0. The command block teleports the one in the Nether. @p is not “the nearest player in this world”: it is the player whose raw x, y and z are nearest, chosen from the list of everybody on the server, compared as though the dimensions were stacked in the same coordinate space. (Type the same command yourself and you always win it — a player’s own source sits at distance zero from a player’s own position.) Nothing in a selector is confined to one level unless you write one of seven options that says so — and four of those seven, distance and the three box deltas, are also what decides whether the game walks every entity in the level or asks the chunk sections for a box.
That is the shape of the whole subject. A selector looks like a filter language, and it is one, but many of its twenty-one option names do more than filter: they write the query plan. This page is about which is which.
The cast
| class | what it decides | when it runs |
|---|---|---|
EntitySelectorParser | the reader, the grammar and thirty-one half-built fields besides it. It owns the selector’s own syntax; the brace grammars of scores and advancements are read by the handlers themselves | parse time |
EntitySelectorOptions | the name-to-handler map — twenty-one entries, filled once by Bootstrap.bootStrap and never again | bootstrap |
InvertableSetOptionState | the three-state machine behind type=!zombie,type=!skeleton: one positive assertion or any number of negations and tags, never both | parse time |
SetOnceOptionState | one boolean, for the four options with no parsed field to test for emptiness — limit, sort, scores, advancements. The other ten once-only options enforce it by checking their own field | parse time |
EntitySelector | the compiled query: thirteen final fields, and no grammar left in them | built at parse time, run later |
EntityArgument | four argument shapes (single or many, entities or players) and the parse-time rejections that enforce them | parse time and on the wire |
CommandSourceStack | the only thing a selector can be resolved against — origin, level, server, permission set | resolve time |
LevelEntityGetterAdapter | the fork in the road: an EntityLookup walk, or an EntitySectionStorage box query (entity lifecycle owns both) | resolve time |
net/minecraft/commands/arguments/selector holds five classes, and every one of them is in the server jar and the client jar. That matters
later.
Three stages, and the last one is not on the parser’s clock
flowchart LR
S["the argument text"]
subgraph P["PARSE — at parse time"]
direction TB
P1["the head sets the defaults"]
P2["each option name finds its handler"]
P3["the handler writes a field or adds a test"]
P1 --> P2 --> P3
end
subgraph C["COMPILE — at parse time"]
direction TB
C1["thirteen final fields"] ~~~ C2["a box, or none"] ~~~ C3["an origin"]
end
subgraph R["RESOLVE — per execution"]
direction TB
R1["which levels"]
R2["which of two structures"]
R3["order, then cut to the limit"]
R1 --> R2 --> R3
end
S --> P
P --> C
C --> R
The three stages a selector passes through — the first two while Brigadier parses, and the third on the Server thread each time a command asks for its argument; the three boxes under COMPILE are what it produces, not steps.
The first two stages happen while Brigadier walks the command tree; the third happens when the command’s lambda asks for its argument. In between, the selector is an ordinary immutable object sitting in a parsed context, which is why an /execute chain can resolve the same parsed selector once per source and get a different answer each time (the execution engine).
Parse: six heads and twenty-one names
EntitySelectorParser.parseSelector reads the character after the @ and
sets four things — a result limit, whether non-players are in scope, an order,
and sometimes a type. There are six, and no more: the default branch of that
switch throws.
| head | limit | non-players | order | extras |
|---|---|---|---|---|
| @a | unbounded | no | arbitrary | typed to player |
| @e | unbounded | yes | arbitrary | adds an Entity.isAlive test |
| @n | 1 | yes | nearest | adds an Entity.isAlive test |
| @p | 1 | no | nearest | typed to player |
| @r | 1 | no | random | typed to player |
| @s | 1 | yes | arbitrary | resolves to the source’s own entity |
Only @e and @n add that test, and only LivingEntity.isAlive makes it
mean anything — it is the override that adds “and has health left” to the
base class’s “and has not been removed”. So a player sitting on the death screen is invisible to @e and @n, and still a target for @a, @p and @r — though twenty ticks after death the body leaves the level’s own player list, so a world-limited selector stops finding it.
Then the bracket. EntitySelectorParser.parseOptions reads a name, looks it
up through EntitySelectorOptions.get, and hands the reader to the handler it
finds. Twenty-one names are registered, counted by reading every
EntitySelectorOptions.register call in EntitySelectorOptions.bootStrap.
| option | what its handler does | repeatable? |
|---|---|---|
| name | compares Nameable.getPlainTextName | one positive, or many negatives |
| team | compares Entity.getTeam — the empty string means no team | one positive, or many negatives |
| gamemode | compares ServerPlayer.gameMode, and drops non-players | one positive, or many negatives |
| type | an id sets the EntityTypeTest; a tag only adds a test | one positive id, or many negatives and tags |
| tag | reads Entity.entityTags — empty means no tags at all | freely |
| nbt | serialises the candidate and compares with NbtUtils.compareNbt | freely |
| predicate | runs a loot condition in LootContextParamSets.SELECTOR (contexts and predicates) | freely |
| scores | a brace map of objective to MinMaxBounds.Ints | once |
| advancements | a brace map of advancement to done-ness, players only | once |
| limit | sets the result cap, rejects anything below 1 | once, never on @s |
| sort | picks one of four orders | once, never on @s |
| distance | a MinMaxBounds.Doubles, rejects negatives, world-limits | once |
| level | a MinMaxBounds.Ints, rejects negatives, drops non-players | once |
| x, y, z | override one axis of the resolve origin, world-limit | once each |
| dx, dy, dz | build the box, world-limit | once each |
| x_rotation, y_rotation | angle ranges that wrap through 360 | once each |
Three of the twenty-one are freely repeatable — tag, nbt and predicate —
because they are the three registered as always available, with no state
object behind them at all. That is why tag=a,tag=b is the idiom for “has
both” and type=zombie,type=skeleton is a parse error rather than an empty
result: InvertableSetOptionState moves to a terminal state the moment a
positive id is accepted, and EntitySelectorOptions.get then refuses the
whole option by name before its handler ever runs. Its other terminal state
is the permissive one — after a negation or a tag, more negations and more
distinct tags are allowed, which is why type=!zombie,type=!skeleton works and why
two entity tags can be written together and AND.
Compile: what a box is, and where it comes from
EntitySelectorParser.getSelector runs once, at the end of the parse, and
turns the pile of fields into thirteen final ones. Two of the thirteen are the
interesting decisions.
The box. If any of dx, dy or dz was written,
EntitySelectorParser.createAabb builds the box from those three, treating
the missing ones as zero and adding one to each maximum — a dx=0 volume is
one block wide, not zero. Otherwise, if distance was written and has a
maximum, the box is a cube of that radius, again with one added to the
positive corner. Otherwise there is no box. So distance=8.. — a minimum with
no maximum — produces no box at all, and dx=3,distance=..64 ignores the
distance entirely when choosing the box, because the delta branch wins.
The origin. If any of x, y or z was written, the position becomes a function that overrides those axes of the source’s position and keeps the rest. Otherwise it is the identity. This is applied per execution, which is what makes x=0 mean the same thing everywhere while every axis left unwritten follows the source at each link of a chain.
Everything else that was written is already a test in a list, in written
order — with exceptions appended afterwards whatever order they appeared in.
EntitySelectorParser.finalizePredicates adds the two rotation tests and the
experience-level test last, and EntitySelector.getPredicate then appends up
to three more at resolve time: the feature-flag test, the exact box test and
the range test. Util.allOf evaluates them in that order and short-circuits,
so the range test, a few multiplications, runs after an nbt
comparison that serialised the whole entity.
Resolve: which levels, which structure, which order
flowchart TD
A["EntitySelector.findEntities"] --> C{"a bare name or a UUID?"}
C -- name --> N["PlayerList.getPlayerByName"]
C -- UUID --> UE["Level.getEntity, level by level"]
C -- neither --> D{"is it the source itself?"}
D -- yes --> S["the source's own entity, if it passes"]
D -- no --> E{"world-limited?"}
E -- "yes: this level" --> B2{"non-players in scope?"}
E -- "no: every level" --> B2
B2 -- no --> PL["a player list, walked"]
B2 -- yes --> H{"is there a box?"}
H -- yes --> I["EntitySectionStorage — only the sections near the box"]
H -- no --> J["EntityLookup — every visible entity, one by one"]
I --> K["sort unless arbitrary, then cut to the limit"]
J --> K
PL --> K
How a compiled selector finds its entities — the players-only selector asks the same questions in the same order, so the figure asks whether non-players are in scope only where the two answers part; the three shortcuts at the top return at once, with no sort and no limit.
Three shortcuts come first. A bare player name goes to PlayerList.getPlayerByName, since a name always takes non-players out of scope; a UUID is looked up with Level.getEntity, level by level, since a UUID always brings non-players into scope; and @s tests the
source’s own entity against the selector’s tests. None of the three is sorted
or cut.
World-limited is a parse-time flag, not a runtime one. Exactly seven
option handlers call EntitySelectorParser.setWorldLimited: distance, x,
y, z, dx, dy and dz. Write none of them and
EntitySelector.findEntities iterates every level the server has. /kill
@e[type=item] is a three-dimension operation.
The two structures are genuinely different. With a box,
Level.getEntities goes through EntitySectionStorage, which visits only the accessible non-empty 16-cubes the box overlaps once widened by two blocks sideways and four below. Without one,
ServerLevel.getEntities goes through EntityLookup, which walks the level’s
entire visible-entity map and calls EntityTypeTest.tryCast on each —
EntityTypeTest being the small “is this the type I asked for, and give it to me typed” interface the whole entity-fetching API is generic over. There
is no index by entity type. type=zombie narrows nothing structurally; it
is a cast applied one entity at a time, ahead of the tests. Of the seven, only distance and the three deltas narrow the entity walk within a level, and only when they add up to a box; x, y and z only confine it to one level and move the box.
And the box path finds things the walk cannot. Level.getEntities also
offers each ender dragon’s eight EnderDragonPart sub-entities to the type
test and the predicate, and every part reports its parent’s type. So a box
query in the End can return the dragon and up to eight more “ender dragons”;
the same selector without a box returns one.
Players never get a box. Both player paths — ServerLevel.getPlayers for
one level, PlayerList.getPlayers for the whole server — are linear walks of
a list, and the box survives only as one more test. @a[distance=..8] costs
what @a costs.
Sort is what takes the limit away. EntitySelector.getResultLimit returns
the parsed limit only when the order is arbitrary, and the unbounded value
otherwise, because a sort has to see everything before it can know what comes
first. When it is the parsed limit, it reaches the level query as an early
abort. So @e[limit=1] stops at the first match, and @e[limit=1,sort=nearest]
collects every match in range, sorts the list and throws all but one away.
@n and @p live in the second mode unless told otherwise: their heads set the nearest order, so they collect first and cut afterwards, and only an explicit sort=arbitrary puts them back in the first.
So the query plan is written by thirteen of the twenty-one names: the seven above, which world-limit the search and build or place the box; limit, which aborts it early; sort, which un-decides part of it by taking the limit away; and the four that drop non-players, level, gamemode, advancements and a positive type=player, which send the whole search to a player list. The other eight only filter what the plan returns.
One permission, checked in two places, for two different reasons
The gate is a single atom, Permissions.COMMANDS_ENTITY_SELECTORS, granted by
LevelBasedPermissionSet from gamemaster upward as the one hard-coded exception in that interface
(permissions). It is the
only permission in the game checked in two different phases, it is read in
seven places, all of them under commands/arguments, and they divide cleanly
in two.
At parse time, EntitySelectorParser.allowSelectors asks the source and
the answer becomes a constructor argument. If it is false, an @ throws
EntitySelectorParser.ERROR_SELECTORS_NOT_ALLOWED immediately, and a bare
name or UUID still parses. This is the check that matters for the three
vanilla commands that take a selector-capable argument with no permission
requirement at all — MsgCommand, EmoteCommands and TeamMsgCommand, so
/msg, /tell, /w, /me, /teammsg and /tm. For an ordinary player
this atom is the only gate on those.
MessageArgument alone treats a refusal as a formatting decision rather than
an error: without the permission the message is taken as literal text, so an
unopped /msg Bob @a sends those two characters. With the permission, an @
that is not a valid selector head is skipped and the scan continues — which is how an address like me@gmail.com survives — but a malformed selector body throws, and
the whole command fails to parse.
At resolve time, EntitySelector.checkPermissions asks again, guarding on
EntitySelector.usesSelector, which only EntitySelectorParser.parseSelector
ever sets. A selector compiled from a bare player name is exempt. The second
check is not redundant, because there is a whole route into the machinery that
never passed the first one: EntitySelector.COMPILABLE_CODEC compiles a
selector out of a text component — the selector content type, the
score name field and the entity NBT data source — and does so with
selectors unconditionally allowed, because a codec has no source to ask. The
resolve-time check is what decides whether a /tellraw written by a data pack may enumerate entities, and it asks the source the component is being
resolved against, never whoever wrote it.
Two more argument types write the same fork by hand
EntityArgument is not the only argument type that will take an @.
ScoreHolderArgument and GameProfileArgument each reimplement the
selector-or-literal fork for a value that is not an entity, and what they do
on the literal side is the interesting half.
GameProfileArgument resolves to profiles, not entities. An @ builds a
selector and is rejected outright if it includes non-players; anything else is
read to the next space and looked up in the server’s name-to-id cache — so
/ban, /whitelist and /op name a player who has never joined this session
and may not exist on this server at all. That lookup is the only thing on this
page that leaves the game’s own data.
ScoreHolderArgument resolves to ScoreHolders, which need not be alive.
Its literal side has four branches in order — * for every tracked holder,
a #-prefixed name taken as a bare string, a UUID searched across every
level, and an online player — with the UUID and player branches falling back to a bare name and the wildcard alone throwing when there is nothing to return
(scores, teams and stored data
for what those names are for).
Both take the permission the same way this page’s own parse does, and both check the compiled selector rather than the text, as EntityArgument does — ScoreHolderArgument for its single-or-many shape, GameProfileArgument for players only.
Four argument shapes, enforced on the query and not on the text
EntityArgument comes in four shapes — one entity or many, players only or
anything — and it enforces the difference on the compiled selector, during
the parse, before the world is touched. A limit above one in a single-target
slot is refused, and so are non-players in a players-only slot.
That is why /damage @e 1 complains before it has looked at anything:
/damage, /ride and /data get entity all take the single-target shape,
and @e is unbounded. /kill @e is fine, because /kill takes the
many-entities shape and neither rejection can fire on it. And @s is exempt
from the players-only test, so /msg @s parses and then finds nobody when
the source is not a player.
The client parses selectors and cannot resolve one
This is what the cast’s five-classes-in-both-jars is for. The client parses a selector by two routes and can run neither.
EntityArgument.listSuggestions builds a real EntitySelectorParser against
the client’s own permission set, parses as far as it can, swallows the
exception and asks the half-finished parser for its suggestions — which is why
completion inside a bracket knows which options are still legal; the tree it
parses against is a real one the server sent (Brigadier and
commands). And
ComponentSerialization decodes a selector content type on the client with
the same compiler the server uses.
What the client cannot do is run one: every find method takes a
CommandSourceStack, and the client’s suggestion source is a
ClientSuggestionProvider. The one place a client resolves a component that
might contain a selector is ServerStatusPinger, whose ResolutionContext
deliberately carries no source, so a server-list description containing a
selector renders as nothing at all.
Questions players ask
Which dimension does /kill @e[type=item,limit=1] kill in? The
overworld, unless there is nothing to kill there. With no world-limiting
option the resolve walks MinecraftServer.getAllLevels, which iterates the
server’s LinkedHashMap of levels in the order they were created — the
overworld put in first, the rest in registry order — and an arbitrary-order
limit reaches the level query as an early abort, so the walk stops at the
first match it finds. Write sort=nearest and the bias disappears, because
a sort has to see every level before it can rank anything.
What does the client suggest for an entity argument? Online player names,
plus — from ClientSuggestionProvider.getSelectedEntities — the UUID of
whatever your crosshair is on. Point at a cow, press tab, and it offers you
that cow.
Is sort=random seeded? No. It is the JDK’s list shuffle, with no world
seed and no RandomSource anywhere near it, so @r is not reproducible from
a save.
Why does distance=..8 not return something 8.9 blocks away? Because the
box is only a pre-filter. The cube built for a maximum of 8 spans −8 to +9 on
each axis, deliberately larger than the sphere it approximates, and the exact
test that follows is MinMaxBounds.Doubles.matchesSqr, which compares squared
distances against pre-squared bounds and so never takes a square root.
For a 1.21-era reader. The parser’s crowd of hasNameEquals / hasNameNotEquals / hasGamemodeEquals booleans is gone, replaced by eight state objects — four
InvertableSetOptionStateand fourSetOnceOptionState— that enforce the same rules structurally.
Where to look
EntitySelector first — thirteen fields and four find methods, and the design
is in them. Then EntitySelectorParser.getSelector for the one place those
thirteen are decided, and EntitySelectorOptions.bootStrap for the grammar players write. LevelEntityGetterAdapter is six methods long and is
where the cost of every selector is settled. Note that the name
EntitySelector is used twice in the game: this one, and an unrelated bag of
predicate constants in world/entity that the mob AI and the hoppers use.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
The execution engine
Verified against Minecraft 26.3 · Part XIII ·
/execute as @a at @s run say hion a server with four players: a command engine with no Java recursion, a fan-out that materialises one player at a time, and a/returnthat deletes work out of a queue rather than unwinding a stack.
Write a data pack that calls a function that calls itself, load it, and the server does not crash. It runs a very large number of commands, logs one line at info, and carries on. That is not a recursion limit — there is no recursion limit, and no depth game rule either. What stopped it was a budget: a count of units of work spent by one outermost command, whose ceiling is a game rule and whose exhaustion is the log line. It is that nothing in command execution uses the Java call stack.
Every construct that nests — /execute … run, /function,
execute if function, /return run — is expressed as queued work on a
heap-allocated deque, and the driver is a flat loop. A stack made of heap
objects costs you nothing except the obvious, and buys you three things a
real stack cannot give: you can inspect it, you can delete pending entries from it before they run, and a tracer can watch the work as it runs. /return and /debug function are built on that one decision.
The cast
| class | what it decides | notes |
|---|---|---|
ExecutionContext | one per outermost command: the queue, the staging list, the budget, the fork limit, the tracer | the whole engine is in its ExecutionContext.runCommandQueue loop |
CommandQueueEntry | a Frame and an EntryAction. That is the entire unit of work | — |
Frame | not a stack frame: a depth, a CommandResultCallback a /return feeds, and a Frame.FrameControl that knows how to delete this frame’s pending work | one object shared by reference across a whole function body |
BuildContexts | walks the stages of a parsed chain, forking sources as it goes | BuildContexts.TopLevel, BuildContexts.Continuation and BuildContexts.Unbound |
ContinuationTask | the lazy fan-out: emits one element’s entry, then re-queues itself | the reason three or more players cost one element’s entry at a time rather than N at once |
CallFunction / IsolatedCall | the only two things besides the top level that open a frame | IsolatedCall’s /return cannot reach the caller |
ExecutionCommandSource | the interface the engine is generic over, which is why none of it mentions CommandSourceStack | CommandSourceStack implements it |
CommandResultCallback | a success flag and an integer. This pair is what “the result of a command” means everywhere in the game | CommandResultCallback.EMPTY short-circuits |
net/minecraft/commands/execution is the whole engine and it is entirely
server-side. Nothing here crosses the network; only the effects of
commands produce packets. What arrives is a ParseResults and a
CommandSourceStack that Brigadier and
commands has already
built: Commands.performCommand flattens the parse into a context chain and
Commands.executeCommandInContext is the door — it reads the two game rules
that bound an execution, installs the context in a thread-local and drives
the loop.
The queue, four moments apart
/execute as @a at @s run say hi, four players online. The command starts
as one entry — a BuildContexts.TopLevel holding the whole parsed chain, at
depth 0 — and the figure is the queue at the four moments after that.
flowchart TD
subgraph T2["1 · that entry ran"]
B1["ContinuationTask over four players"]
end
subgraph T3["2 · the task ran"]
direction LR
C1["ExecuteCommand for A"] --> C2["ContinuationTask"]
end
subgraph T4["3 · A's say hi ran"]
D1["ContinuationTask — B is still not made"]
end
subgraph T5["4 · the task ran again"]
direction LR
E1["ExecuteCommand for B"] --> E2["ContinuationTask"]
end
T2 --> T3 --> T4 --> T5
The whole queue at four moments — an arrow inside a panel runs from the head to the entry behind it, an arrow between panels is one entry run, every entry shown shares the top frame at depth 0, and ExecuteCommand here is the leaf task in commands/execution/tasks, not the /execute command’s class of the same name.
A fork does not create frames, and it does not create entries.
BuildContexts.execute walks every non-execute stage inside a single
queue entry, spending one cost unit per modifier stage — here as @a and
at @s, not run — no matter how many sources that stage produces, and turning a one-element source list into an
N-element one. Frames are opened in exactly three places:
ExecutionContext.createTopFrame, CallFunction and IsolatedCall. A
hundred-player fork opens none.
The queue is a stack with a staging buffer, and that is what makes it
depth-first. An action does not push directly: it appends to
ExecutionContext.newTopCommands while it runs, and
ExecutionContext.pushNewCommands splices that list onto the head
afterwards, in order. So whatever the current action spawned runs before
whatever was already pending — the semantics of a call stack, out of an
ArrayDeque. A queued action is handed the ExecutionContext itself. A custom executor or modifier is handed an ExecutionControl instead, the (context, frame) pair, through which it can queue an action, read or install a TraceCallbacks, and reach its current frame — which is how /return finds the frame it discards.
The fan-out is lazy, and the arithmetic is exact.
ContinuationTask.schedule queues nothing for an empty list, one entry for
one element, two entries for two, and for three or more queues
exactly one: a ContinuationTask that emits the current element’s entry and
then re-queues itself behind it. Because staging preserves order, element i and everything it spawns runs to completion before element i+1 runs — and, from three elements up, before it is even materialised. The queue cost is constant in N.
A chain can split across entries, and only a custom modifier does it.
When the stage walk meets a CustomModifierExecutor it hands off and
returns mid-walk; the rest of the chain resumes later as a
BuildContexts.Continuation. That is how execute if function and
/return run interrupt a chain that otherwise runs to its leaf inside one
entry.
Deleting work, which is what /return is
/return does not unwind and does not throw. Frame.returnSuccess pushes
the value sideways into the callback the caller installed on that frame, and
Frame.discard splices the abandoned work out of the queue. There is no
search.
The splice is one rule. Call the discarding frame’s own depth d: pop from the head while the entry’s depth is at least d. That works because the queue is depth-first, so entries deeper than a frame are always in front of that frame’s own remaining entries — which means the rule removes exactly the callee’s pending work plus the rest of this frame’s body, and nothing older. Depth-zero frames are the special case: their frame control clears the queue outright.
The laziness pays off here too. Discarding one ContinuationTask self-entry
abandons every element not yet materialised, so /return out of a
thousand-line function is the same cost as /return out of a two-line one.
Who installed the callback decides where a returned value goes, and two details settle it:
- The single-source reduction on the
/return runpath lives inBuildContexts.execute, not inReturnCommand, and it is gated twice — on return mode, and on the leaf not being aCustomCommandExecutor. Soreturn run execute as @a run function fooqueues one function call per player, unreduced — and the first to finish, by returning or by falling through, discards the rest. - The chaining runs the opposite way from “inner onto outer”: what is
chained is the source’s own callback with the current frame’s return
consumer, and on the
/return run functionpath the outer frame’s consumer is chained into the inner frame.
A result is a flag and a number, and only a function tag sums them
The result of a command is always a CommandResultCallback pair: a success
flag and an integer. The engine aggregates nothing, and there is one exception, which /function makes. A fork
over N players delivers N independent results to N sources, so an execute store result whose target every forked source shares — one placed before the fork, or one naming a fixed holder — writes N times and the last one wins — there is no
success count. The exception is /function on a tag, which sums its members’ results, and only when the caller installed a real callback and it is not under return run.
A command typed in chat has an empty frame callback and
Commands.performCommand returns nothing — yet execute store still works
on it, because the result also reaches the source’s own callback, which
is what ExecuteCommand.wrapStores decorated
(scores, teams and stored data).
FallthroughTask makes a chain that produced no sources, or a called function that ended without /return, fail rather than return nothing, and every site that queues it is
inside a return or a conditional.
Six classes implement the escape hatch for a command that wants the engine
rather than Brigadier’s plain “return an int”, and between them they belong to four commands: /function, /return, /debug and /execute, whose if function is one. Two of the six
extend CustomCommandExecutor.WithErrorHandling —
FunctionCommand.FunctionCustomExecutor and
DebugCommand.TraceCustomExecutor — which routes a thrown
CommandSyntaxException to both the source’s error handler and its
callback, so a failing /function under execute store still writes. The
other four handle their own.
What actually stops a command
Three things end an execution before its work is done. One is an exception thrown out of an action, which Commands.executeCommandInContext passes up: Commands.performCommand tells the source the command failed, and ServerFunctionManager.execute only logs it. The other two are the budget running out and the queue overflowing, and they are not the same event.
The budget runs out. ExecutionContext.runCommandQueue checks at the top
of every iteration, logs at info, and breaks. The queue is not cleared;
it is simply abandoned with the context. Nothing reaches the player.
The queue overflows. ExecutionContext.queueNext trips when staged plus
queued entries exceed ten million — a cap on queue length, not on
depth, whatever the constant that names it suggests; ExecutionContext.handleQueueOverflow
clears both lists and sets a latch that silently drops every subsequent
queue attempt, and the driver then logs at error. Different level,
different clean-up, and a latch the budget path has no equivalent of.
What the two numbers are
Both are game rules, and both are 65536 by default.
GameRules.MAX_COMMAND_SEQUENCE_LENGTH is the budget — how many units of
work one outermost command may spend before the budget path above fires — and
GameRules.MAX_COMMAND_FORKS is the fork limit, how many sources one stage
may contribute. Both are read once, by the outermost command, so a
/gamerule changed part way through a long fan-out does not take effect
until the next top-level command. (There is nothing dimensional in it:
ServerLevel.getGameRules returns the server’s one GameRules instance, so
no level has rules of its own to pick up.) The fork limit is checked per
contributing source with a greater-or-equal comparison, so the effective
ceiling is one below the configured value, and when it trips the handler
returns without queueing even a FallthroughTask — a /return run chain
that hits it yields nothing at all rather than a failure.
Depth is not on that list, and that is the answer to the function at the top of this page. Recursion is unbounded structurally: depth is used only to order discards and to indent the tracer. What bounds a self-calling function is the budget, transitively — every call it makes spends a unit — and what bounds a fan-out is the fork limit, with the budget again at one unit a leaf.
And a budget belongs to a context, not to a command.
Commands.CURRENT_EXECUTION_CONTEXT is a thread-local: a command that
starts another top-level execution while one is running queues its work into the running context, to run next, rather than making a new context, and the limits were read once by the outermost call. Its top frame sits one depth deeper than the running entry’s, except under a depth-zero entry, where it shares depth zero; every vanilla re-entry is a function call whose body opens a frame one level further down, so its discards never reach the outer queue. The thread-local is null again the
moment a queue drains, so two commands run back to back each open their own
context — which is why every function in #minecraft:tick gets a budget of
its own (functions and
macros).
Where a unit is spent
The budget is spent in exactly three places — BuildContexts on a
modifier stage, CallFunction on a function call, and the leaf
ExecuteCommand task on an executed command — and the first has a gate
worth knowing. The increment happens only when the stage carries a non-null
redirect modifier, and only after the custom-modifier hand-off has been
ruled out. So a plain execute run costs nothing for its redirect, and the execute if function and /return run stages are free: neither custom modifier ever reaches the counter, though what they go on to run pays like anything else. A ContinuationTask is free too, and that is
the +1 an N-way fan-out does not pay: N leaves cost N, the continuation that
materialised them costs nothing, and each modifier stage that forked in the
first place was charged one unit, however many sources it produced.
The two commands that are part of the engine
ExecuteCommand.scheduleFunctionConditionsAndTest is how execute if function works and it is the cleverest thing in the area: instantiate each
function once, wrap every source in an IsolatedCall whose callback appends
that source to a list, then queue a BuildContexts.Continuation over the
same mutable list, which the isolated calls fill before the continuation
reads it. The staging order is what makes that safe, and the inner
CallFunction opens against the isolated frame, which is why a /return
inside a condition function cannot reach the caller.
/debug function installs a DebugCommand.Tracer on the whole ExecutionContext rather than per frame, so it traces everything in that context. The command refuses to nest a trace and refuses return mode, and the tracer implements CommandSource as well, so a traced
function’s chat output lands in the trace file alongside the call lines.
Questions players ask
Can a function yield? No. Work it queues drains inside the same driver
loop, in the same tick, before the call returns. The only escape is
/schedule (functions and macros).
Why did my command fail silently inside execute if? Because
conditionals are fork nodes. execute as @s, execute at @s,
execute if block and friends set the forked flag on ChainModifiers for
the rest of the chain, and a forked source suppresses failure messages.
Putting a harmless-looking conditional in front of a command converts its errors into nothing: in forked mode Brigadier catches a plain command’s error and returns zero, which is all a trace shows of it, though the engine’s own commands, a forked /function naming a missing function among them, still write their error to a trace through CommandSourceStack.handleError.
Does /return inside a fork stop the other sources? Not normally. A forked /function has its N calls queued at once, in a plain Java loop, and each opens its own frame at the next depth when it runs, so a /return in one discards only
that callee. It is return run … that sets CallFunction.returnParentFrame against the caller’s own frame, making the inner discard run at the outer
frame’s depth and delete the siblings.
Where to look
ExecutionContext and Frame first — two short files that explain the whole design. Then BuildContexts for how a parse becomes work,
ContinuationTask for the laziness both the fan-out and the function body
ride on, and ExecutionCommandSource for why none of it names a command
source.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Functions and macros
Verified against Minecraft 26.3 · Part XIII · A macro function in
#minecraft:tickis reached with no arguments: it fails, silently, twenty times a second, forever — nothing logged, nothing printed, nothing counted.
Put a $-prefixed line in a function, add that function to
#minecraft:tick, and the game will call it every tick with no argument
compound at all. Instantiating it raises a
FunctionInstantiationException, ServerFunctionManager.execute catches
that one exception with an empty body, and the tick moves on. Any
other exception from the same call is logged at warn; this one is the
single case the manager was written to swallow, because the /function
command is expected to report it instead — and #minecraft:tick is not the
/function command.
That is the sharp end of a model that is otherwise very tidy: a
.mcfunction file becomes a runnable thing in two steps, and for the
overwhelming majority of functions the second step does nothing at all. A
third stage below is the hand-off — what the engine does with the result —
and it belongs to another page.
The pipeline
The figure is the whole model: which kind of function a file becomes, and where the one silent failure leaves it.
flowchart TD
F[(".mcfunction file")]:::disk --> C["1 · compile, at reload"]
C --> K{"a dollar line?"}
K -- no --> PT["PlainTextFunction"]
K -- yes --> MF["MacroFunction"]
PT -- "2 · instantiate: itself" --> R["InstantiatedFunction"]
MF -- "2 · instantiate" --> X{"arguments given, parses?"}
X -- yes --> R
X -- no --> EX["FunctionInstantiationException"]
EX --> SW["a command reports it,<br/>ServerFunctionManager swallows it"]
R --> Q["3 · queued: CallFunction opens a frame"]
Q --> E["the execution engine runs the lines"]
A function’s two steps and the hand-off — compiled once at reload, instantiated on every call, and only the macro branch can fail there; the failure is reported or swallowed depending on who made the call.
The two halves of that live in net/minecraft/commands/functions (the model)
and net/minecraft/server (the two managers), and both are server-side but for StringTemplate, which a dialog’s command template also runs on the client (dialogs).
The cast
| class | what it decides |
|---|---|
ServerFunctionLibrary | the reload listener: parses every file, holds the volatile function and tag maps, and the compile-time PermissionSet |
ServerFunctionManager | the runtime face: the two tags, the source they run as, and the one empty catch |
CommandFunction | the compiled, uninstantiated function — CommandFunction.fromLines is the compiler |
PlainTextFunction | a macro-free function. It is both the compiled and the instantiated form, so instantiating one allocates nothing |
MacroFunction | the other case: parameters, an eight-entry LRU, and a re-parse per miss |
StringTemplate | the $(name) syntax and what a valid variable name is |
InstantiatedFunction | an id and an ordered list of UnboundEntryActions. That is the entire runnable representation |
FunctionInstantiationException | carries a Component, which is what /function shows when it reports the failure |
1 · Compile, at reload
In: the raw lines of one file. Out: a CommandFunction, or nothing
at all.
CommandFunction.fromLines walks the lines. A trailing backslash joins the
next one (CommandFunction.shouldConcatenateNextLine), and a continuation
at end of file is an error; blank lines and # comments are skipped; a
leading / is a hard error with two different messages depending on whether
you wrote one slash or two; a leading $ is a macro line, kept as a template; and
anything else goes through CommandFunction.parseCommand, which produces a
BuildContexts.Unbound. So a compiled function line is literally a parsed
context chain plus its input string, waiting for a source.
CommandFunction.checkCommandLineLength caps a line at two million
characters.
A syntax error on any plain line fails the whole file, which is logged at error and then simply absent from the map. There is no partial function.
Two constraints make this stage unusual, and they are the same constraint
seen twice. ServerFunctionLibrary parses every file in parallel on the
reload’s background executor, and it does so against a source built by
Commands.createCompilationContext with a null level and a null server.
Only the last stage — collecting the parses, logging the files that failed, resolving the function tags and swapping in the maps — runs on the thread that applies the reload, and the maps are volatile, the library object being built on a background thread. An argument type that dereferenced the world during parsing would fail every function that used it — which is exactly why FunctionArgument reads an id and
defers the lookup. No argument type tests the constraint:
the four argument types whose parse reads the source at all consult only
its permissions, and those come from the dedicated server’s function-permission-level property, gamemaster by default, gamemaster always in singleplayer, and owner on the game test server.
2 · Instantiate, per call
In: a CommandFunction and an optional argument compound. Out: an
InstantiatedFunction, or a FunctionInstantiationException.
For a PlainTextFunction this step returns the very same object: nothing
is allocated, nothing is parsed, and the overwhelming majority of functions
in the world take this path.
A MacroFunction looks up each declared parameter in the argument compound,
stringifies it, and uses the ordered list of strings as a cache key over
an eight-entry LRU (MacroFunction.MAX_CACHE_ENTRIES). On a miss it
substitutes into every macro line and re-parses it, and a parse failure
there is the exception above. Note what the cache is keyed on: the values,
in parameter order — so nine distinct argument tuples cycling round will
miss every time.
Stringification is where macros surprise people, because it is not SNBT for
everything. MacroFunction has explicit cases for float and double (a
decimal format with up to fifteen fraction digits, so 1.0 becomes 1),
for byte, short and long, and for strings (the unquoted value).
Everything else — integers, compounds, lists — falls through to SNBT.
Integers merely happen to render bare; byte, short and long need their own
cases precisely because SNBT would suffix them.
StringTemplate also decides what a parameter may be called: inside $(…), letters, digits and underscore and nothing else — any letter or digit in Unicode’s basic plane counts — checked by StringTemplate.isValidVariableName at compile time.
That rule is borrowed — a dialog’s input keys obey it too, which is what lets
a dialog substitute its inputs into a command (dialogs).
Three smaller rules complete the model. One $ line anywhere makes the
whole file a macro function, though its non-macro lines keep their
already-compiled form and are never re-parsed. A $ line containing no
substitution at all is a load-time error, not a plain command. And a
macro function’s instantiated variants all share one synthetic id,
derived from the parameter names rather than the values.
Then: queued, and gone
In: an InstantiatedFunction and a source. Out: entries on the
execution queue.
CallFunction spends one cost unit, opens a frame at the next depth, and
hands the function’s line list to ContinuationTask.schedule — the same
call the fan-out over an entity selector makes. A hundred-line function
and a hundred-player fork are therefore the same shape in the queue, which
is what makes /return cheap: discarding one self-entry abandons every line
not yet materialised. Everything after this point is
the execution engine.
What calls a function, and when
ServerFunctionManager.tick runs in the first profiler zone
MinecraftServer.tickChildren opens — commandFunctions, with only the
suspension of every player’s packet flushing ahead of it — and so before the
clocks, before the time sync, before any level ticks, and long before
connections and players tick, which happen after the levels (the
server tick). It no-ops entirely when the
tick-rate manager is not running normally, so /tick freeze suspends the tick and load tags. #minecraft:load runs once after a reload or start, and
#minecraft:tick runs every tick from a list snapshotted at reload and
never consulted again, so nothing can join or leave the tick loop between
reloads.
That list is where the failure at the top of this page lives. Nothing in
ServerFunctionManager.tick carries an argument compound, so a macro
function on the tag can never be instantiated; the manager’s one empty catch
swallows the exception; and the snapshot means the function is tried again
on the next tick and on every tick after it, for as long as the pack is
loaded. There is no counter and no log line, and the way to see it is to call the same function from a command — /function, /debug function or execute if function — each of which reports what the tick tag does not.
Each function in the tick or load tag gets its own ExecutionContext — its own copy of the budget the engine spends, rather than one shared across the tag
(the execution engine).
/schedule is the one way out of the current tick, and it books its callback
into the server-wide TimerQueue that only the overworld’s clock advances
(the level tick) —
so a scheduled function fires once per tick, not once per dimension, and
stands still while the world is frozen. What belongs to functions rather than
to the clock is what ScheduleCommand refuses outright: a delay of zero, and a macro function named on its own — a tag holding one is accepted, and fails when it fires as silently as the tick tag’s.
Everything else that runs a function is a short list: /function itself, with /return run function; execute if function and /debug function, the two commands that are part of the engine (the execution engine);
AdvancementRewards, whose CacheableFunction holds the id and resolves it
once (advancements); RunFunction,
one of the EnchantmentEntityEffects
(enchantments); and
TestEnvironmentDefinition.Functions for a game test’s environment setup
(game tests).
The two permission verbs
A function body is one of the places a command source’s permission set is deliberately rewritten, and the game reaches the same answer down two differently-named routes.
ServerFunctionManager.getGameLoopSender takes the server’s own source —
which is LevelBasedPermissionSet.OWNER — and calls
CommandSourceStack.withPermission with gamemaster. That is a flat
replacement: the tick and load tags run at gamemaster.
FunctionCommand (for /function and execute if function) and DebugCommand instead call CommandSourceStack.withMaximumPermission at gamemaster, which is
PermissionSet.union — and for the level-based sets a player or the console carries, that union returns the lower of the two rather than the
higher (permissions). So an
owner’s source comes out at gamemaster on this route as well. Both verbs land on the same rung, and no route runs a function body’s source above gamemaster. Which commands the body’s lines may run was settled earlier, when they were parsed against the compile-time permissions above: Brigadier does not ask a node’s requirement again when it runs.
Where to look
CommandFunction for the compiler, and MacroFunction for the only part of
it that runs per call. ServerFunctionLibrary for what a reload does off the thread that applies it, ServerFunctionManager for the tick hook and the one empty
catch, and StringTemplate for the substitution rules a pack author will trip over.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Advancements
Verified against Minecraft 26.3 · Part XIII · “Stone Age”: a cobblestone lands in your inventory and the toast is on its way before the next tick ends — delivered by a subscription table that shrinks as you play, over a packet that never says what a criterion tests.
Mine a stone block. Nothing about advancements happens when the item enters
the inventory. What happens is that the pickup’s ServerPlayer.take runs
AbstractContainerMenu.broadcastChanges — the same diff that keeps
your client’s inventory in sync (containers and
menus)
— which notices that a slot’s contents differ from its remembered copy, and reports
the difference. Detection is a diff, not
an event, and the advancement system is a subscriber to it.
That is the first of two things this system does backwards from expectation. The second is bigger: there is no global list of who is listening for what. Each player carries their own subscription table, it contains only the criteria that player has not yet satisfied, and it shrinks as they play. A veteran player is cheaper to run than a new one, and a save’s cost falls as its players play.
Which is also why advancements are the game’s general-purpose “tell me when
the player does X” facility rather than only a goal list. The recipe book
is unlocked by advancements. PlayerPredicate reads advancement progress
back out as a loot condition. Of the fifty-eight registered triggers, fifty-seven are hooks something in the game fires, and the remaining one, minecraft:impossible, is never fired at all.
The cast
| class | what it decides | side |
|---|---|---|
Advancement | the immutable definition — parent, DisplayInfo, rewards, criteria, requirements, and a pre-rendered display name built in the six-argument constructor, which is the [Title] every announcement quotes | both |
AdvancementHolder | the id plus the advancement, with id-only equality — so a map keyed by holder survives a pack changing an advancement’s contents | both |
AdvancementTree | the parent/child graph, built by a fixed-point loop that refuses any advancement whose parent is not yet a node. An orphan is discarded, not re-rooted | both |
Criterion / CriterionTrigger | a trigger plus a decoded CriterionTriggerInstance, and nothing else — a criterion has no name of its own, the name is the map key, and the trigger object is stateless | server |
SimpleCriterionTrigger | the base class for all but one trigger, and the owner of the per-fire sweep | server |
AdvancementRequirements | a list of lists of criterion names: an AND of ORs. AdvancementRequirements.size counts clauses, not criteria | both |
PlayerAdvancements | the per-player subscription table and the dirty sets. The only class here with interesting state | server |
TreeNodePosition | a full tidy-tree layout — three walks, threads, ancestors, shifts — run on the server, mutating AdvancementNode’s coordinates in place | server |
The shared model is net/minecraft/advancements, the triggers are in
advancements/triggers and the predicates in advancements/predicates
(with the entity half a level down). All 111 classes ship in both jars — the
side column above says where a class does its work, not which jar carries
it — and PlayerAdvancements sits in net/minecraft/server beside them.
CriteriaTriggers registers fifty-eight triggers into
BuiltInRegistries.TRIGGER_TYPES over forty-four classes; the gap is
re-use, with PlayerTrigger alone accounting for six registrations.
From a slot that changed to a toast
minecraft:story/mine_stone has one criterion, get_stone, whose trigger
is minecraft:inventory_changed and whose condition is a single
ItemPredicate over the #minecraft:stone_tool_materials tag. It has no
rewards at all.
sequenceDiagram
box Server
participant SP as ServerPlayer
participant ACM as Abstract<br/>ContainerMenu
participant ICT as Inventory<br/>ChangeTrigger
participant PA as PlayerAdvancements
end
box Client
participant CPL as ClientPacketListener
participant CAdv as ClientAdvancements
end
rect rgba(0, 0, 0, 0.04)
Note over SP,PA: the pickup, inside ServerPlayer.doTick, after the levels
SP->>ACM: broadcastChanges, from ServerPlayer.take
ACM->>SP: ContainerListener.slotChanged, on the player's own listener
SP->>ICT: trigger — count all 43 slots first
ICT->>ICT: SimpleCriterion<br/>Trigger.trigger
ICT->>ICT: test the changed stack
ICT->>PA: award, after the sweep
PA->>PA: unregisterListeners, then the rewards
PA->>PA: markForVisibilityUpdate — the root
end
rect rgba(0, 0, 0, 0.04)
Note over SP,PA: the next tick's ServerPlayer.tick
SP->>PA: flushDirty, second to last in the tick
PA->>PA: updateTreeVisibility — the whole tree
end
PA->>CPL: ClientboundUpdate<br/>AdvancementsPacket
CPL->>CAdv: update — rebuild, reconcile progress, add a toast
One stone pickup through the advancement system — the award happens inside the pickup, after the levels have ticked, and the one packet that tells the client waits for the next tick’s flush.
The paragraphs below take its arrows in order.
Counting comes before knowing whether anyone cares. The slot change
reaches the trigger through the ContainerListener every ServerPlayer
installs on its own menus, whose ContainerListener.slotChanged fires
CriteriaTriggers.INVENTORY_CHANGED.
InventoryChangeTrigger.trigger walks all forty-three slots — thirty-six
inventory plus seven equipment — to compute the occupied, full and empty
counts before it asks whether any criterion is listening. That is the floor cost of every change to a player’s inventory, forever. (The one fired unconditionally is CriteriaTriggers.TICK, once a tick for every player.)
The sweep itself is the cheap part. SimpleCriterionTrigger.trigger
fetches this trigger’s map from PlayerAdvancements.getTriggerMapForType and
returns immediately if it is null — and it is null once every criterion
for that trigger is satisfied, because PlayerAdvancements.removeListener
deletes the per-trigger map when it empties. When there is work, it builds
one LootContext and reuses it for the whole sweep, evaluates the
caller’s cheap matcher first and the player predicate only for matches,
and does not allocate the results list until the first hit. Note the scope:
one player. Nothing here is a broadcast.
Matches are collected, then awarded, because awarding calls
PlayerAdvancements.unregisterListeners, which mutates the very map being
iterated.
Completion is checked against the requirements, not the criteria.
AdvancementRequirements.test is the AND of ORs. Here it is one clause of
one name, so granting the criterion completes the advancement — which fires
the rewards, the chat announcement (built by
AdvancementType.createAnnouncement, broadcast to every player, gated on the display’s own flag and on GameRules.SHOW_ADVANCEMENT_MESSAGES) and the visibility dirty flag.
The rewards are AdvancementRewards.EMPTY here, as for most advancements outside the recipe book.
The record holds experience, loot tables, recipes and a function, and the
recipe field is why the recipe book works at all: every recipe advancement
is generated with a RecipeUnlockedTrigger criterion and rewards naming the
recipe, so earning it calls ServerPlayer.awardRecipes
(recipes).
RecipeUnlockedTrigger then closes the loop by letting other advancements
observe an unlock — comparing the recipe key by reference identity,
which is safe only because ResourceKeys are interned. The function reward
is a CacheableFunction, which holds the id and resolves it once
(functions and macros).
The table shrinks, and the two things that refill it
PlayerAdvancements.registerListeners subscribes a player only to criteria
that are not yet done in advancements that are not yet done, and every award
unsubscribes. The longer you play, the smaller your table, and the cost of the system to a save falls as its players play.
Two things run it backwards. /advancement revoke re-subscribes what it
revoked, and a reload — /reload, or /datapack enabling or disabling a pack — re-subscribes everything unfinished in the new pack.
Neither is something the game does on its own, which is why the table shrinks as you play rather than only ever shrinking.
Two oddities sit on the same machinery. ImpossibleTrigger has no trigger
method at all — it is the one trigger implementing CriterionTrigger
directly, and it exists so a node can anchor a tree while being ungrantable
except by command, which vanilla uses for exactly one file: the invisible
root of every recipe advancement. (It has nothing to do with /trigger,
which is a scoreboard feature.) And
PlayerAdvancements.checkForAutomaticTriggers is dead code in the strictest
sense: it walks every advancement on every player load, but its whole body
sits behind this advancement has no criteria at all, and Advancement’s
criteria codec rejects an empty map outright. No loaded advancement can
satisfy the guard, so the loop never does anything.
Visibility is per root, and it gates the wire
PlayerAdvancements.markForVisibilityUpdate dirties the root, and the
flush — PlayerAdvancements.updateTreeVisibility, once per dirty root — re-runs AdvancementVisibilityEvaluator (the “how far past your frontier can you see” rule, with a depth of two) over that root’s entire subtree. Finishing one advancement in a large tree re-evaluates the whole
tree, and only the nodes whose visibility flipped go on the wire.
PlayerAdvancements.flushDirty then sends one
ClientboundUpdateAdvancementsPacket, with progress only for advancements
in PlayerAdvancements.visible. Progress on something hidden, or beyond
your frontier, accumulates server-side and reaches the client the moment it
becomes visible.
Where the flush sits in the tick is worth pinning down, because it produces
a real one-tick delay that nobody expects. Inside ServerPlayer.tick,
AbstractContainerMenu.broadcastChanges is the fifth statement,
CriteriaTriggers.TICK fires mid-tick, and
PlayerAdvancements.flushDirty is second to last, with only a
post-effects check after it. So everything awarded between those points — a minecraft:tick criterion, or an item that another advancement’s reward grants, whose diff runs inside that award — coalesces into one packet, and so does everything awarded earlier in the tick: a melee kill arrives in the packet drain of MinecraftServer.processPacketsAndTick, and a typed /advancement grant as a task the server runs before it, both before the levels tick at all.
But ServerPlayer.tick is not the last thing that happens to a player: it is
only the first of the two brackets a player is ticked in (the two-phase
tick).
ServerGamePacketListenerImpl.tick calls ServerPlayer.doTick during the
connection phase, which runs after the levels
(the server tick) — i.e. after
PlayerAdvancements.flushDirty has already run. CriteriaTriggers.LOCATION, which fires there
every twenty ticks and is what most vanilla biome and structure
advancements hang on, therefore always lands in the next tick’s packet —
and so does any award a pickup earns, the stone above included, because
ServerPlayer.take runs the diff there.
A criterion’s conditions are loot conditions
Each entity condition a trigger carries — the player test every simple
trigger has, a kill’s victim — is one LootItemCondition, held in a
registry holder and evaluated against a LootContext reached through
EntityPredicate.createContext. So
a trigger’s conditions are exactly the machinery of
contexts and predicates, and that is
where most descriptions of the system stop.
It is worth going one step further, because the predicate package is where four shapes were invented that the whole data-driven half of the game now reuses.
| shape | what it generalises | where else it turns up |
|---|---|---|
MinMaxBounds | the numeric range, with both a codec and a StringReader grammar | 3..7 means the same in a predicate, an entity selector and /random |
CollectionPredicate | one generic “N of these match”, composing CollectionContentsPredicate and CollectionCountsPredicate | its only users are the seven component predicates in core/component/predicates |
EntitySubPredicate | every part of an entity predicate as a registry element instead of a code branch | its twenty-five kinds are each a record and a codec, a few with a builder beside them |
DataComponentMatchers | testing a stack’s components without knowing what any of them are | data components |
Two details change behaviour rather than shape.
LootItemCondition.CODEC accepts either the id of a data-pack predicate
or one inline condition — though vanilla’s own advancements take the inline
form every time, so in a criterion the reference exists for pack authors
rather than for the game.
And EntityPredicate declares an explicit type-check-first, NBT-last
ordering for its own sub-tests — a performance invariant hiding inside a
predicate class.
The screen at the other end
The client’s half is five classes in net/minecraft/client/gui/screens/advancements, ClientAdvancements over in client/multiplayer, and the toast, and it is the payoff for
everything the server did. It does no tree layout: it is drawing
positions a data-pack reload decided, which is why the tree looks the same
on every client connected to a server.
The layout itself is TreeNodePosition, run once per root by AdvancementTree.repositionNodes
from ServerAdvancementManager’s constructor — which a reload runs on a background worker,
after RegistryDataLoader has read the JSON (the resource
system).
It writes each AdvancementNode’s coordinates in place, and they ride the packet in
ClientboundUpdateAdvancementsPacket.PositionedAdvancement. A
root with no DisplayInfo is never laid out and never becomes a tab, and a
display-less node in the middle of a tree is transparent — the layout skips
it and adopts its children. One wrinkle in an otherwise deterministic
algorithm: AdvancementNode.children is an unordered hash set, so sibling
order inside a tidy-tree layout is hash-dependent.
AdvancementsScreen is a ClientAdvancements.Listener, and its AdvancementsScreen.onAdvancementsUpdated
rebuilds every tab from the tree. ClientAdvancements.setListener makes that call on a new listener
at once, which is how the screen catches up on open. Each packet is applied by
ClientAdvancements.update: it removes and adds tree nodes, places each added one at the coordinates
the packet carries, reconciles every progress it was sent against that advancement’s requirements, and — for each
now done whose display asks for it — adds an AdvancementToast, which plays
a sound only for a challenge. Then it makes the same call, so an open screen rebuilds after every packet.
AdvancementsScreen owns the tab strip; AdvancementTab owns one root’s
pan-and-scroll bounds, auto-centres on first render and clamps the drag;
AdvancementWidget scales the server-decided coordinates by fixed factors,
draws the connector lines to its parent, and wraps its own tooltip text.
AdvancementTabType is the one with a hard limit in it: four header
geometries with room for eight tabs above, eight below and five on each
side. A data pack’s twenty-seventh root is silently unreachable.
Two more things ride this boundary for almost nothing.
ServerboundSeenAdvancementsPacket has a “closed screen” action that is
serialised, deserialised and dropped. And Advancement.sendsTelemetryEvent
is consumed only on the client, by WorldSessionTelemetryManager, and
only for advancements in the minecraft namespace — which is the whole
reason a flag rides a wire form that drops the criteria and the rewards
(what this book skips).
What the package holds that this page does not name
Two families make up most of net/minecraft/advancements, and the answer to
both is the same: the page explains the shape and the members are instances of
it. The forty triggers it does not explain — KilledTrigger,
FishingRodHookedTrigger, RecipeCraftedTrigger and the rest — are each a
record, a codec and a CriterionTriggerInstance, registered by CriteriaTriggers and fired
from wherever in the game the thing happens; knowing SimpleCriterionTrigger
is knowing all of them. The concrete predicates under
advancements/predicates are the same story one level down:
StatePropertiesPredicate, EntityFlagsPredicate, EntityEquipmentPredicate,
MobEffectsPredicate, DamageSourcePredicate and their neighbours are each a small record over one kind of thing, the entity ones instances of EntitySubPredicate, and the one that matters most to another part, LocationPredicate, is named where it does work, in contexts and predicates.
Questions players ask
Why does the client show “3/7” if it does not know what the criteria
are? Because AdvancementRequirements is on the wire and
AdvancementProgress.update reconciles against it. Advancement.STREAM_CODEC
reconstructs the record with an empty criteria map and
AdvancementRewards.EMPTY, so a client cannot know what any criterion
tests, or that an advancement grants anything at all. And
AdvancementProgress.getProgressText returns nothing at all when there is
one clause, which is why a single-criterion advancement never shows “1/1”.
A criterion’s whole per-player state is one class,
CriterionProgress, and one field in it: a nullable timestamp. That is why
the wire form can carry progress without carrying a single condition.
(An AdvancementProgress with no requirement clauses cannot be complete — AdvancementRequirements.test returns false for an empty list rather than vacuously true — and every progress starts that way, decoded from the wire or the disk or built empty, until AdvancementProgress.update gives it its requirements, which both sides do at once.)
Does /reload roll back my progress? No, and the order is the point: a
reload’s completion list saves every player before it reloads them (the
resource
system),
so PlayerAdvancements.reload re-reads a file written moments earlier. What is genuinely lost is progress for any
advancement the new pack has removed or renamed — logged once each, and
invisible to the player except as a full reset packet — plus the selected
tab, which is silently forgotten with no packet, so the client keeps a stale
one.
When is progress written to disk? Only when the player is saved. There is
no write on award: PlayerAdvancements.save runs from PlayerList.save, on disconnect, on every save of the world — the autosave, /save-all, shutdown — or from the reload above. The definitions come
from data/<ns>/advancement/<id>.json through Advancement.CODEC — a
file that fails to decode aborts the reload outright — and per-player state is one JSON
file at players/advancements/<uuid>.json
(LevelResource.PLAYER_ADVANCEMENTS_DIR), data-fixed on load through
DataFixTypes.ADVANCEMENTS.
Why is /advancement grant usable as a conditional? Because a no-op is
a hard failure: granting an advancement the player already has throws rather
than reporting zero. AdvancementCommands.Mode — only, through, from, until — picks one advancement or collects its parents or children or both (its everything constant goes unused), and /advancement grant … everything, which takes every advancement directly, calls
PlayerAdvancements.flushDirty before the loop with the packet’s “show
advancements” flag true and after it with the flag false, which is
the only purpose that flag has: suppressing a toast storm from the batch it
just granted.
Why does the toast stay silent? Because AdvancementToast asks for a
sound only when the advancement is an AdvancementType.CHALLENGE — one of the three
AdvancementTypes, and the one that also gets its own title colour. For a
task and a goal the method returns nothing at all, so the toast slides in
in silence. The client can tell which it is because DisplayInfo carries the
type; what it cannot tell is whether the advancement was meant to announce
itself in chat, because that flag never goes on the wire. The
serialiser packs three flags into an int and omits it, and the reader
hard-codes it false while the codec defaults it true, so the client’s copy is
wrong for the common case rather than merely unused.
Where to look
Advancement and AdvancementRequirements for the model;
PlayerAdvancements for everything that happens;
SimpleCriterionTrigger and InventoryChangeTrigger for the hot path;
MinMaxBounds and CollectionPredicate for the predicate shapes that recur
everywhere else; and AdvancementVisibilityEvaluator for the one rule
nobody guesses right.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Scores, teams and stored data
Verified against Minecraft 26.3 · Part XIII ·
execute as @a store result score @s ticks_frozen run data get entity @s TicksFrozen: one command writing a scoreboard through a callback the inner command has never heard of, and two of the three data modelsexecute storeexists to join.
Look at the sidebar on a well-built server and some of the names in it are not players. #total, constant, .timer — rows belonging to nothing alive. That is not a hack layered on top of the scoreboard; it is the scoreboard working exactly as written, and one method override explains it.
Entity.getScoreboardName returns the entity’s UUID string.
Player.getScoreboardName overrides it with the profile name. There is one
flat map from string to a PlayerScores — a row of scores, holding players by name, mobs by
UUID, and anything else you care to type. From that single override follows
the whole of scoreboard folk practice: why fake players exist (nothing
checks that a key belongs to an entity), why a mob’s score can never
appear in the tab list (whose holder is built from a profile name), and
why renaming a player orphans their scores.
execute store is why four systems share this page. It is the only
construct in the game that takes the result of an arbitrary command and
writes it somewhere, and it has exactly three sinks — a score; a path
into a block, an entity or a storage; and a boss bar’s value or maximum.
Each of those is a named thing on this server that a number can be written
into, and each brings its own model with it: the scoreboard, which is the
game’s general-purpose number per thing; the NBT path language, a query
language for reaching into any tag at all, over command storage, which is a
place to put a tag belonging to no block and no entity; and the boss bar,
which turns out to be the scoreboard’s shape one floor up. Teams are the
fourth, and they are here because they live in the scoreboard’s package and
its class — read by systems that have nothing to do with scores.
The instinct they share is worth stating before the classes, because it explains three otherwise-odd decisions: the server is the only participant that knows anything. The client is sent scores it can draw and nothing else — not an objective’s criteria, not a score’s lock bit, not which objectives exist. There is no serverbound packet in this whole system. Every write a player asks for is a command.
The cast
| class | what it decides | side |
|---|---|---|
Scoreboard | six maps and nothing else, plus ten empty hooks for a subclass to fill. A pure data structure, used unmodified by the client | both |
Objective | criteria, display name, render type, number format, auto-update — and a back-pointer to the scoreboard, so every setter reports its own change | both |
Score | four mutable fields: the value, a lock bit, a display component and a number format. ReadOnlyScoreInfo, PlayerScoreEntry and ScoreAccess are the other three faces of it | both |
ScoreHolder | the interface Entity implements, with ScoreHolder.forNameOnly minting anonymous ones — the most consequential class on the page | both |
PlayerTeam | the only subclass of Team: the mutable state, the setters, a precomputed display style, and friendly-fire plus see-invisibles packed into one wire byte | both |
ServerScoreboard | three fields — the server, ServerScoreboard.trackedObjectives, and one dirty boolean — and thirteen overrides, the ten hooks and three methods that change team membership and display slots, which between them broadcast what changed and mark the save dirty. It lives in net/minecraft/server, not beside Scoreboard | server |
NbtPathArgument | 874 lines, the largest argument type in the game, and a whole query language: six node kinds and a depth limit of 512 | both |
DataCommands | /data, over three DataAccessors — BlockDataAccessor, EntityDataAccessor and StorageDataAccessor. Beside it, ScoreboardCommand, TeamCommand and TriggerCommand write the scoreboard, as execute store does | server |
net/minecraft/world/scores is the whole model, and every class in it ships in both jars. Beside it:
CommandStorage, a lazy façade over one SavedData per namespace, so
minecraft:foo and mypack:foo are different files; and net/minecraft/network/chat/numbers, holding NumberFormat with the three kinds NumberFormatTypes registers.
One command, two models, and a number that lands in a third place
The figure follows that command from the engine’s stage walk to the client’s
sidebar; BuildContexts is the engine’s walker over a parsed chain (the
execution engine).
sequenceDiagram
box Server
participant BC as BuildContexts
participant ExecC as ExecuteCommand
participant DataC as DataCommands
participant SS as ServerScoreboard
participant SA as ScoreAccess
end
box Client
participant CPL as ClientPacketListener
end
BC->>BC: as @a — one source becomes one per player
BC->>ExecC: store result score — the modifier, once per source
ExecC->>ExecC: storeValue — @s and the objective already resolved
BC->>DataC: getData, the leaf, queued once per source
DataC->>DataC: build the entity's whole save tag
DataC->>DataC: follow the path, collapse the tag to one int
DataC-->>ExecC: the result, to the source's callback
ExecC->>SS: getOrCreatePlayerScore
ExecC->>SA: set
SA->>SS: onScoreChanged — only if changed or new
SS->>CPL: ClientboundSetScorePacket — only for a displayed objective
CPL->>CPL: handleSetScore — the owner is only a name
One execute store result score for one player — the store is fixed on the source before the inner command runs, the number reaches it by callback, and two separate gates decide whether the write is a packet.
The paragraphs below take its arrows.
The store target is resolved before the inner command runs, not after.
ExecuteCommand.wrapStores builds the store node as a redirect with a
modifier, not as a step after the leaf. The modifier resolves the score
holder and the objective against this source, then decorates the source
with a callback chained onto whatever was already there. So the store is a
property of the source, which is why several stores compose, and why each
forked player writes their own row without the leaf command knowing a
scoreboard exists.
The result travels by callback, not by return value — the source’s own
callback, which is precisely why execute store works on a command typed in
chat whose frame callback is empty
(the execution engine).
A failing command under store result writes 0, whichever kind it is.
On the custom-executor path that is CustomCommandExecutor.WithErrorHandling doing what it does for every executor built on it (the execution
engine),
so a failing /function writes 0. For an ordinary leaf the result consumer is
driven by Brigadier, and
ContextChain.runExecutable catches the CommandSyntaxException and calls
the consumer with success false, result 0 before rethrowing. The game
hands that consumer straight through from the source’s own callback, so the
two paths agree: a store target written by a command that threw holds zero,
not its previous value.
/data get builds the whole entity to read one field.
EntityDataAccessor.getData produces the entity’s entire save tag, freshly,
and the path then walks it. That is the real cost of /data get, and it is
why a per-tick /data get on a busy entity is a measurable expense.
Four rules collapse a tag to one integer. A numeric tag floors its double
value; a collection and a compound both yield their size; a string yields
its length. Nothing yields the value you might have meant:
data get entity @s Inventory returns the number of stacks you are
carrying, not 36 — Inventory.save writes only the non-empty slots, so the
collection whose size you get is as short as your inventory is empty. A path matching more than one tag is an error; a path matching
none is a different error.
The write can be a silent no-op, and may never reach the wire at all.
ScoreAccess.set writes nothing and sends nothing when the value is
unchanged and the score is not new — unless the objective has auto-update
on, in which case it refreshes the display name first and a changed display
counts as a change on its own. And ServerScoreboard.onScoreChanged is
gated on the objective being in ServerScoreboard.trackedObjectives, whose
only entrance is occupying a display slot.
Why a write is a handle
Scoreboard.getOrCreatePlayerScore does not return a Score. It returns a
ScoreAccess — an anonymous object closing over the score, the objective
and the holder, plus two decisions computed once, at handle-creation
time: may this be modified (did the caller ask for a force-writable
handle, or is the criteria not read-only), and was this score newly
created by the lookup that produced this handle.
That is the whole answer to “why not a setter”. A setter would have to
re-derive the first fact on every call, and the second is unrecoverable
after the fact — once the Score exists, nothing can tell whether this
call created it. Newness is what decides whether an unchanged value still
needs a packet, so it has to survive from the lookup to the write. The
handle is also the one place that knows when to fire the change hook, which is why a write goes through ScoreAccess.set, ScoreAccess.add, ScoreAccess.increment, ScoreAccess.reset, ScoreAccess.lock, ScoreAccess.unlock, ScoreAccess.display or ScoreAccess.numberFormatOverride; only removal — Scoreboard.resetSinglePlayerScore, Scoreboard.resetAllPlayerScores and Scoreboard.removeObjective — and loading from the save go round it.
Nothing here is ticked. MinecraftServer mentions the scoreboard
five times in total — the field, the constructor, the load, the getter, and
one call to ServerScoreboard.storeToSaveDataIfDirty inside
MinecraftServer.saveAllChunks. There is no periodic sweep and no
dirty-queue drain: every mutation that reaches the wire sends its packet synchronously, inside the call that made it, and there is one scoreboard per server, not
per level, so scores and teams are global across dimensions.
Names that belong to nobody, and the # that hides them
The opening’s fake players are not one mechanism but two, and the # in
front of #total does a different job in each.
In ScoreHolderArgument a leading # skips entity resolution entirely, so
the token is taken as a literal name and no lookup happens. Its literal side has four branches in order — the wildcard *, a # name, a UUID searched across every level, and an online player — and the UUID and player branches fall back to a bare name when they find nothing. The wildcard does not: with
no tracked holders at all it throws.
In the sidebar the same character means something unrelated:
PlayerScoreEntry.isHidden filters the row out of the display. One
character, two mechanisms, and together they are the whole
hidden-fake-player idiom — a row that can be written by name, read by name,
and never drawn.
Nothing about that requires an entity to exist. ScoreHolder.forNameOnly
mints a holder from a string — it is all ClientPacketListener.handleSetScore
ever builds from a score packet — and the scoreboard’s one flat map does not
care where the string came from.
What a criterion can be, which is nearly anything
ObjectiveCriteria looks like an enum of eleven values and is not.
Forty-three constants exist, thirty-two of them generated — sixteen
team-kill and sixteen killed-by-team criteria, one per team colour. Six are
read-only: health, food, air, armour, experience and level.
And then the tail. Stat extends ObjectiveCriteria, so every
statistic in the game is a criterion, and ObjectiveCriteria.byName
parses a colon-separated name by looking the left half up as a stat type and
the right half in that stat type’s own registry.
minecraft.mined:minecraft.stone is not a special case; it is the
statistics registry addressed through a string. Nine stat types over the
block, item, entity-type and custom-stat registries make thousands of valid
criteria names, which is why /scoreboard objectives add accepts far
more than the forty-three bare criteria names — its suggestions offer those
and every stat name in every stat type’s registry.
The identity-keyed reverse index is what makes that cheap.
Scoreboard.objectivesByCriteria is an identity map from criteria to
the objectives watching it, ServerPlayer.awardStat hands the Stat object
itself to Scoreboard.forAllObjectives, and object identity finds the
watchers — sound only because each stat object is interned, one per value, in its StatType.
Criteria-driven scores are the one part of this page with a schedule, and it
is narrower than it sounds. Scoreboard.forAllObjectives has seven call
sites and every one is in ServerPlayer: one inside the helper each of the six read-only criteria is updated through, one for the death count, two for the kill counts, one that fires
both team-kill criteria, and two for statistics — one awarding, one
resetting. No criterion is driven from Entity, LivingEntity or
Mob, so a skeleton killing a zombie increments nobody’s kill count —
though Entity.load does write to it, calling Scoreboard.addPlayerToTeam when the tag it loads names a Team — a field the game reads but never saves, so it arrives only in a tag a command or an item supplies. The six read-only criteria are change-detection diffs — six
consecutive comparisons against remembered fields — living in
ServerPlayer.doTick, which runs in the connection phase, after the
levels have ticked (the level tick). So
damage taken during the level tick reaches the scoreboard, and the wire,
later in the same tick rather than during it.
The path language, and the accessor that is coarser than it looks
Six node kinds — a named child, a match on an object, a match on the root object, a match on a list element, all elements, and an index — with a depth limit of 512.
The elegant part is creation. The parent-creating walk goes through the
nodes and, for each one, asks the next node what shape its parent has to
be: a named child wants a compound, an index wants a list. So a set through a.b.c materialises two compounds without any node knowing more than its own type — though an index never creates an element, so through a.b[0].c the walk makes a and an empty list b, finds nothing at [0], and fails. Removal is the same walk with a
plain lookup instead, so it never creates.
The three accessors, by contrast, are coarse. DataAccessor has two methods
that matter — read the whole tag, write the whole tag — and no path-aware
write anywhere. Every /data modify is read everything, mutate in
memory, write everything back, which is why a block-entity write reloads
the block entity and marks the chunk dirty, and why an entity write
round-trips the entity through its own load path and then restores the UUID
by hand, because loading would have overwritten it. What each accessor
does contribute is its own grammar subtree, which is how DataCommands
builds the target half and the source half of every subcommand from one list
of three providers applied twice.
Teams, which systems far from scores read
Team declares everything a reader asks for and PlayerTeam is its only
subclass. What makes teams worth their own paragraph is who else consults them: collision through
EntitySelector.pushableBy; nametag visibility through
LivingEntityRenderer.shouldShowName; invisibility through
Entity.isInvisibleTo; friendly fire through Entity.doTeamsAllowDamage; and
death-message routing through ServerPlayer.die. Only ServerPlayer.die and
LivingEntityRenderer.shouldShowName are reached from anywhere near one
place; EntitySelector.pushableBy has six call sites, Entity.doTeamsAllowDamage seven, among them Player.canHarmPlayer and the attacks of a few mobs, and Entity.isInvisibleTo two. A sixth system is joined to teams both ways. Every team join, leave and modification calls through to ServerWaypointManager to remake the locator bar’s connections, and remaking one reads the team’s colour to re-colour its icon.
Two team behaviours are worth pinning. Team.isAlliedTo is reference
equality, so two teams with byte-identical settings are never allied;
every “same team?” test in the game is really “same object?”, safe only
because Scoreboard.teamsByName is the single owner of every instance. And
a team has two visibility settings of which only one ships: the wire
parameters carry nametag visibility, while death-message visibility has a single reader in play, ServerPlayer.die, and the client never learns the rule
because it does not need to. Nametag visibility also widens: when an
entity has a team, LivingEntityRenderer.shouldShowName returns from the
team switch directly and never reaches the checks that hide a name behind
F1, for the camera entity, or for a vehicle — so a mob on a team set to
always keeps its name through the HUD toggle.
What the client is ever told
Five packets, and one field that gates four of them
Five packets, all server → client, and no serverbound counterpart exists:
ClientboundSetObjectivePacket, ClientboundSetDisplayObjectivePacket,
ClientboundSetScorePacket, ClientboundResetScorePacket and
ClientboundSetPlayerTeamPacket. All five reach every player, through PlayerList.broadcastAll or a loop over the player list — no distance filter, no dimension filter
(what the client is told).
An objective in no display slot does not exist on the network.
ServerScoreboard.onObjectiveAdded sends nothing at all; the only path into
the tracked set is ServerScoreboard.setDisplayObjective. A scoreboard with
two hundred objectives and an empty sidebar costs zero bandwidth — and
putting one into a slot then ships every score it holds, to every player,
at once. The join burst is the same shape:
PlayerList.updateEntireScoreboard sends every team with its full member
list, then walks all nineteen display slots and ships each distinct
occupying objective with all of its scores.
What it is not told, and how it draws anyway
What the client is told is also less than it looks. ClientPacketListener
constructs every objective it receives with ObjectiveCriteria.DUMMY, so a
client cannot tell a health objective from a dummy one — it only knows to
draw hearts — and the score packet carries no lock bit, which is why
/trigger’s suggestions have to be computed on the server.
How a score is drawn is settled by a third object again, and it has its
own fall-back order. A NumberFormat may ignore the number entirely —
FixedFormat renders a constant component whatever the score is — and
resolution runs per-score override, then per-objective, then a per-site
default: red in the sidebar, yellow in the tab list, unstyled below the
name. Below-name numbers are the odd one out: they are computed in Entity
rather than in a renderer, and their range is an attribute,
Attributes.BELOW_NAME_DISTANCE, syncable, default ten, maximum 512, so a
server can change how far away a player’s below-name score is legible, per
entity (attributes).
There is a third route by which a score reaches a client, and it carries no
score packet at all: a {"score":…} or {"nbt":…} in a text component.
ScoreContents and NbtContents resolve on the server, against the
authoritative scoreboard, and put the result on the wire — never the
reference (text components). A
/tellraw is a photograph, not a subscription.
What survives a restart, and what does not
Saving is one boolean for the entire scoreboard, cleared by re-packing the
whole thing, and it happens only when the world is saved — the autosave,
/save-all, or shutdown. A crash thrown by the tick itself is a shutdown: it falls into the same finally, which calls MinecraftServer.saveAllChunks, whose very first statement is ServerScoreboard.storeToSaveDataIfDirty. What loses a score is an ending that never reaches that method — a crash relayed from a worker thread, which the shutdown’s own drain throws again before the save, a watchdog kill, a kill -9, a power cut — and then every score since the last autosave goes with it (how a
server dies). ScoreboardSaveData sits under
minecraft:scoreboard beside the world, with one command-storage file per
namespace, both through the data fixer
(level data and rules). The NBT
field names are the archaeology — Objectives, PlayerScores,
DisplaySlots, Teams, and inside them Name, CriteriaName,
RenderType, Locked — capitalised, and preserved by codec.
The third sink is a boss bar, and it is this page’s shape again
execute store result bossbar <id> value writes into a CustomBossEvent,
and a reader who has the scoreboard already knows the model. A boss bar is a
named server-side thing holding a number, saved with the world, broadcast to
the players attached to it, and writable only by a command.
BossEvent is the shape everything shares: an id, a name, a progress float
from zero to one, a BossEvent.BossBarColor, a BossEvent.BossBarOverlay,
and three booleans that ask the client to darken the screen, play the boss
music and draw the world fog. ServerBossEvent adds the live membership — a
Set<ServerPlayer> — and turns every setter into a broadcast, which is the
whole of the sending side the book had not named: one
ClientboundBossEventPacket with six operations, add and remove and one
update apiece for progress, name, style and properties. There is no
serverbound counterpart, exactly as there is none for a score. Four things in
the game own one: WitherBoss, Raid, EnderDragonFight — and
CustomBossEvent, which is /bossbar.
What CustomBossEvent adds is the part that rhymes. It keeps an integer
CustomBossEvent.value and an integer CustomBossEvent.max, derives the
float progress from them on every write, and holds its membership as a
Set<UUID> beside the superclass’s set of live players. The UUID set is
what persists and what /bossbar set … players edits; the live set is
maintained by CustomBossEvent.onPlayerConnect, which re-attaches a player
whose UUID is on the list, and by CustomBossEvent.onPlayerDisconnect, which
calls the superclass’s removal, so that the UUID stays. A bar’s
membership outlives the session the same way a score outlives the entity.
CustomBossEvents is the SavedData that holds them, one file at
data/minecraft/custom_boss_events.dat (level data and
rules), and its NBT is the same
archaeology as the scoreboard’s — Name, Visible, Value, Max,
Players, capitalised, preserved by codec. BossBarCommands is the write surface, at gamemaster, and an overload of ExecuteCommand.storeValue, the score sink’s own name, is the store sink: a sibling of the score sink
rather than the same code, chaining the same kind of callback onto the source
and writing the value or the maximum instead of a ScoreAccess. What the
client does with the packet — the interpolation, the sky it darkens — is the
HUD.
Questions players ask
Why is my sidebar not DisplaySlot.SIDEBAR? If the local player is on a
team with a colour, Hud uses that colour’s own display slot and falls
back to the plain sidebar otherwise. The colour-to-slot mapping lives on
TeamColor, not on DisplaySlot, and it is what the sixteen team sidebars
are for. The sidebar shows fifteen rows and hides before it cuts: hidden
rows are filtered out, then the rest sorted by value descending and name
case-insensitively, and then truncated to fifteen.
Why did /trigger say the objective is not enabled? Score’s lock bit
starts locked and its codec defaults unlocked, so a score created by
/scoreboard players set is locked while a score loaded from a file that
omits the field is not. /trigger is the only command an unprivileged
player can use to write a score, and its gate is three-part — the criteria
must be the trigger criteria, the score must already exist, and it must be
unlocked — and the command re-locks it immediately, so each enable buys
exactly one use. It is also, with /teammsg (and its /tm alias), one of the two commands in this area registered with no permission requirement at all.
Why did my execute store throw an internal error? Because
/scoreboard refuses a read-only objective and execute store does not
check. The command resolves its write targets through
ObjectiveArgument.getWritableObjective; ExecuteCommand.wrapStores uses
the plain lookup and never asks for a force-writable handle. So
execute store result score @s <a health objective> reaches
ScoreAccess.set with modification disallowed and raises a raw runtime
exception from inside a result callback rather than a command error. The
same hole is reachable through /scoreboard players operation with ><,
the one operator that writes both sides.
Why did my execute store into a data target do nothing at all? Its
read-mutate-write is wrapped in a catch with an empty body: a player target, an uncreatable path, a path too deep — no message, no failure, no write. The score sink has no such
catch.
Why can I read a player’s NBT but not write it? The entity accessor’s
write path rejects any Player before doing anything else, and the read
path has no such check. That one asymmetry is why every player-NBT technique
is read-only. Relatedly, a no-op is a hard failure across the area — /data merge, /data modify, /data remove and /scoreboard players enable all throw when they changed nothing, as do most of /team’s setters and every one of /bossbar’s, which
makes them usable as conditionals in a function, the same choice
/advancement grant makes.
Do a mob’s scores survive it despawning? They survive unloading and
die with the mob: Scoreboard.entityRemoved runs from the level’s
destruction callback and is gated on the entity being both non-player and
not alive.
Where to look
Scoreboard for the six maps, ScoreAccess for why a write is a handle,
and ServerScoreboard.trackedObjectives for the one field that decides which objectives and scores a client’s scoreboard ever holds. ObjectiveCriteria.byName for the statistics bridge,
NbtPathArgument.NbtPath for the tidiest piece of the area, and
ExecuteCommand.wrapStores for the one that makes execute store stop
feeling like magic.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Dialogs
Verified against Minecraft 26.3 · Part XIII · You click a server in the multiplayer list and, before the world has loaded — before you are in a world at all — a form appears with text boxes on it, and a vanilla server sends one only from a command it registers behind debug flags.
A dialog is a data pack’s form: a title, some body text, some inputs and
some buttons, decoded from JSON and put on your screen. Nothing about that
is surprising until you notice which protocol phase it works in.
ClientboundShowDialogPacket is registered in both the play and the
configuration protocols, with a different codec in each, so a server can
interrupt the join handshake to ask you something — configuration being the
phase a joining client is parked in, and the one a play session can be sent
back to (protocol
phases). Vanilla
only ever does it from a dev-flag-gated command — but the machinery is
there, complete, in the shipped jar.
And the reason it works there is not a special case bolted on; it is a
second codec, and it explains itself. The configuration buffer is a plain
byte buffer with no registry access (packets and stream
codecs),
so the packet cannot carry a holder id. Dialog.CONTEXT_FREE_STREAM_CODEC therefore sends the whole dialog
inline. The dialog is encoded with no registry access at all, so a dialog it refers to goes inline too, and an element that can only be named by id — an enchantment on an item body’s item, say — cannot be sent at all.
A dialog is also one of this part’s two clearest instances of a pattern found across the game — game tests is the other — a thing made a registry element loaded from a data pack rather than a Java class. That argument is made once, for
all its instances, in
the data-driven type pattern; this
page assumes it. Four of the pattern’s registries are dialog
registries: BuiltInRegistries.DIALOG_TYPE for the dialog itself, and
BuiltInRegistries.DIALOG_BODY_TYPE,
BuiltInRegistries.INPUT_CONTROL_TYPE and
BuiltInRegistries.DIALOG_ACTION_TYPE for the three kinds of part inside
it.
The cast
| class | what it decides | side |
|---|---|---|
Dialog | the registry element. Dialog.DIRECT_CODEC dispatches on BuiltInRegistries.DIALOG_TYPE; there are two stream codecs, and which one is used decides the whole page | both |
CommonDialogData | what every dialog embeds: titles, whether escape closes it, whether it pauses the game, the after-action, the body elements and the inputs. Its MapCodec is where the pause validation lives | both |
DialogAction | close, none, or wait-for-response — and DialogAction.willUnpause is what that validation tests | both |
InputControl | TextInput, SingleOptionInput, BooleanInput, NumberRangeInput. An Input is a key plus a control, and the key must be a valid macro variable name | both |
Action | produces an optional ClickEvent from the live input values, through Action.ValueGetter | client |
ClickEvent | extended with ClickEvent.ShowDialog, which is how a chat message, a book, a dialog or a sign can open a dialog, and ClickEvent.Custom, which carries a custom action | both |
DialogScreens | the codec-to-screen-factory map, with DialogScreen as the base and DialogControlSet owning the live getters | client |
DialogConnectionAccess | the phase-specific way back to the server — and the configuration-phase one refuses to run commands | client |
net/minecraft/server/dialog spans four packages, all in the server jar; the screens that render them are client-only in net/minecraft/client/gui/screens/dialog — SimpleDialogScreen for both simple kinds, and MultiButtonDialogScreen, DialogListDialogScreen and ServerLinksDialogScreen over an abstract ButtonListDialogScreen — which is the same
one-more-screen pattern GUI and
screens covers everywhere else. The five kinds
DialogTypes.bootstrap registers are NoticeDialog and ConfirmationDialog
(both SimpleDialog) and MultiActionDialog, DialogListDialog and
ServerLinksDialog (all ButtonListDialog) — and both of those supertypes
are interfaces, not classes.
From a JSON file to a click the server reads
sequenceDiagram
box Server
participant DlgC as DialogCommand
participant SP as ServerPlayer
participant MS as MinecraftServer
end
box Client
participant CComPL as ClientCommon<br/>PacketListenerImpl
participant DlgS as DialogScreen
participant DCS as DialogControlSet
end
DlgC->>SP: openDialog
SP->>CComPL: ClientboundShowDialogPacket — a holder id, or inline
CComPL->>CComPL: handleShowDialog — a screen for the dialog's type
CComPL->>DlgS: the new screen
DlgS->>DCS: addInput, per input — a live getter each
DCS->>DCS: the click — the action reads the getters now
DCS->>DlgS: runAction, with the click event it made
DlgS->>CComPL: DialogConnectionAccess.<br/>sendCustomAction
CComPL->>MS: ServerboundCustom<br/>ClickActionPacket
MS->>MS: handleCustom<br/>ClickAction
One /dialog show and one click on a custom button — the input values are read at the click, by the control set that has held a live getter for each input since the screen was built, and the packet that carries them back reaches a handler that, in vanilla, writes a debug log line and nothing else.
The trace turns on one decision: when are the input values read? Not at
packet time and not at screen construction. DialogControlSet.addInput
registers a live Action.ValueGetter for each input as the screen is built,
and Action.createAction calls them at the
moment of the click — which is why the same Action object produces a
different command each time, and why CommandTemplate can be a template
rather than a string. The button hands the click event it made to
DialogScreen.runAction, and a custom one leaves through
DialogConnectionAccess.sendCustomAction, the phase’s way back to the
server. ActionTypes registers nine kinds. Seven are
generated in a loop over the click-event kinds a server is allowed to send,
each keeping that kind’s own name, so a click-event kind is automatically a
dialog action — and the one kind that is not allowed, opening a local file,
can never be one. The other two are registered by hand under names of their
own: CommandTemplate as dynamic/run_command, and CustomAll as
dynamic/custom, which packs every input value into an NBT compound.
The packet on the way out is the play-phase form, which names a registered dialog by its holder id and sends an unregistered one inline; in the configuration phase the same packet is always inline, for the reason at the top of this page.
Nothing on the server side of this ever ticks. A dialog is a packet send
from whatever ran the command or handled the click; the reply hops off the
Netty thread onto the server’s PacketProcessor before
MinecraftServer.handleCustomClickAction sees it, and on the client
ClientCommonPacketListenerImpl.handleShowDialog hops to the client’s
processor before touching the screen stack. Exactly one thing in this system
ticks: WaitingForResponseScreen, counting ticks to reveal and then enable
its Back button.
Four ways a dialog opens, and two of them never ask the server
Four things open a dialog in play; the configuration-phase sender, DebugConfigCommand, sends its packet itself (below). Two of the four go through ServerPlayer.openDialog, the server-side entry point, and the other two open it on the client with no packet at all.
/dialog show is the obvious one. A click event dispatched on the
client is the interesting one, because “a component with a click event” is
not the same as “a component whose click events are dispatched”: there are exactly three places on the client where a show dialog event actually is — chat, a book, and DialogScreen itself, which dispatches its own buttons and body text.
An item’s name or lore is tooltip text and dispatches nothing.
A sign is clicked, but its click event is never dispatched on the client. SignBlockEntity
reads the event server-side and calls ServerPlayer.openDialog
directly — the same way it runs a run command event, and the reason a sign
is the one clickable thing the client never gets to vet
(permissions).
A tag is the fourth, and it needs no server at all in the moment.
DialogTags.PAUSE_SCREEN_ADDITIONS and DialogTags.QUICK_ACTIONS let a data pack put dialogs behind a pause-menu button and behind a hotkey (with the shipped tags empty, that button opens the server’s links, when it sent any), and Dialogs holds the
three the jar ships. Closing one from the server is
ClientboundClearDialogPacket, and it is registered in both phases, as the two packets already on this page are —
ClientboundShowDialogPacket going out and
ServerboundCustomClickActionPacket coming back. Three packets, both
protocols, one system.
What a dialog is made of
Inside a dialog, the parts dispatch on registries of their own the same way
the dialog does: DialogBody over BuiltInRegistries.DIALOG_BODY_TYPE
(PlainMessage and ItemBody), InputControl over
BuiltInRegistries.INPUT_CONTROL_TYPE, and ActionButton carrying a
CommonButtonData of label, tooltip and width. DialogBodyTypes,
InputControlTypes and ActionTypes are the three bootstraps that fill them
in code, StaticAction is the plain “do this fixed click event” action, and
DialogCommand is /dialog itself. DialogBodyHandlers and
InputControlHandlers are the client-side factory maps that mirror them.
One of those parts reaches outside the system. An input’s key is validated
by ParsedTemplate against StringTemplate.isValidVariableName — the same
rule a macro function’s parameters obey — which is why every input can be named in a CommandTemplate, and the seam into
functions and macros.
What a data pack cannot do
Three defences are built into the model rather than into any particular dialog, and each one exists because the feature would otherwise be a way to trap a player.
The exit is not optional. DialogScreen’s initialisation is final: it
always adds a warning button that opens a nested confirm screen offering
to disconnect, and repositions it if a layout would push it off-screen. A dialog whose after-action waits for a response swaps in WaitingForResponseScreen, which
reveals a Back button after a second and enables it after five.
Pausing is validated by the codec, wherever it decodes. A dialog that
pauses the game with an after-action that never unpauses is rejected,
because it would strand the player in a paused world. The check sits on
CommonDialogData’s codec rather than on the loader, and MapCodec.validate is
applied to both directions of the codec — so it runs on the server as it
encodes and on the client as it decodes, which is what covers a dialog sent
inline in the configuration phase.
A button that runs a command is not simply a chat command. It goes
through ClientPacketListener.sendUnattendedCommand, which parses it up to twice and, on three of its four outcomes, shows you a confirmation screen before
sending anything (permissions
owns the four outcomes). What is this system’s own is the phase: the
configuration-phase DialogConnectionAccess refuses to run a command at all,
logging a warning instead, so a button on a dialog shown before you are in a
world can do everything except that.
The extension point vanilla does not use
MinecraftServer.handleCustomClickAction is one line, logging at debug.
The entire custom-action mechanism — an arbitrary id plus an arbitrary NBT payload, sent from a dialog, a chat message or a book (a sign’s is handed to it on the server) — exists for data packs and
server software to build on. The game itself only defines the transport, and
defends it with a 32 KB NBT accounter and a 64 KB cap on the payload’s own length prefix.
The same is true one level up: the only vanilla sender of a
configuration-phase dialog is DebugConfigCommand, which the game registers only in a dedicated server’s command set, and only when started with its debug system properties (Brigadier and commands). A server really can put a form in front of you before you are in the world. Vanilla does it only there.
Where to look
Dialog and CommonDialogData for the model, then Action — the
value-getter indirection is the only subtle thing in the whole system.
DialogScreens for how a codec becomes a screen, and
MinecraftServer.handleCustomClickAction for the one line that is the
extension point.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Game tests
Verified against Minecraft 26.3 · Part XIII · Run
/test run *on a vanilla server and one test runs, and passes. The suite is not in the game — a test is a data-pack file, the Java body is a value the JSON points at, and the shipped jar declares exactly one of each.
Game tests are how Mojang checks that a piston still pushes and a hopper still pulls: a small structure is pasted into a spare corner of a world, a test body runs against it for a bounded number of ticks, and a block beside it turns green or red. That much is the familiar part. The unusual part is where a test lives.
There is no GameTest annotation and no test registry class. A
test is a registry element loaded from data/<ns>/test_instance/, and
the Java body — when there is one — is a value in a second registry,
Registries.TEST_FUNCTION, that the JSON points at. The Java half is a
payload, not the declaration. Which means the shipped jar contains
GameTestInstances’ single always-pass instance, BuiltinTestFunctions’
body for it, and GameTestEnvironments’ default environment — an empty
TestEnvironmentDefinition.AllOf — and the real suite lives in Mojang’s
test sources, not in the game you downloaded.
This is the data-driven type pattern
again, and game tests are its most complete instance. Five registries hold the system: Registries.TEST_INSTANCE and Registries.TEST_ENVIRONMENT are loaded from a data pack, BuiltInRegistries.TEST_INSTANCE_TYPE and BuiltInRegistries.TEST_ENVIRONMENT_DEFINITION_TYPE are the built-in type registries the JSON in those two dispatches on, and Registries.TEST_FUNCTION holds the Java bodies.
The cast
| class | what it decides |
|---|---|
GameTestInstance | the registry element. GameTestInstance.run takes a GameTestHelper and is the body — BlockBasedTestInstance needs no Java at all, FunctionGameTestInstance invokes a Registries.TEST_FUNCTION entry |
TestData | the declaration record every instance delegates to: environment, dimension, structure, tick budgets, required, rotation, manual-only, the two retry counts, sky access, padding |
TestEnvironmentDefinition | the seven ways to bend the world for a test, shaped as an undo log |
GameTestBatch | a group of tests keyed by their environment holder and their dimension. A batch is an environment, in one dimension |
GameTestRunner | owns the batches and the two structure spawners, and runs a test again when ReportGameListener, reading the run’s retry options or the test’s own two retry counts, asks it to |
GameTestInfo | one run of one test: its position, its timeout, its sequences, its outcome |
GameTestHelper | the entire surface a test body sees — coordinate translation, world edits, spawning, assertions, outcomes |
TestInstanceBlockEntity | the block entity that owns a test’s bounding box, status and beacon beam, and does the real work of placing, saving and encasing the structure |
Every class in net/minecraft/gametest/framework is server-side, and net/minecraft/gametest/Main is the headless entry point beside it. The
screens that author a test are not in it — they are client-only and live
with the rest of the GUI, and they are below.
The objects, and how they nest
What a test is and what running it builds are two sets of objects, and the figure shows what each one holds.
classDiagram
class GameTestInstance {
<<abstract>>
TestData info
run(GameTestHelper)
}
class TestData {
Identifier structure
int maxTicks
int setupTicks
int maxAttempts
}
class TestEnvironmentDefinition {
<<interface>>
setup(ServerLevel)
teardown(ServerLevel, saved)
}
class GameTestBatch {
Collection gameTestInfos
ResourceKey dimension
}
class GameTestInfo {
int tickCount
}
class GameTestHelper {
GameTestInfo testInfo
}
class TestInstanceBlockEntity
GameTestInstance --> TestData : info
TestData --> TestEnvironmentDefinition : environment
GameTestBatch --> TestEnvironmentDefinition : keyed by
GameTestBatch --> GameTestInfo : fifty by default
GameTestInfo --> GameTestInstance : test
GameTestInfo --> TestInstanceBlockEntity : the block in the world
GameTestInfo ..> GameTestHelper : a new one at tick zero
A run at the top — a batch is one environment’s worth of test runs in one dimension — and the declaration below it, an instance, its record and the environment the record names; a solid arrow is a field, labelled with its name or its meaning, and the dotted one is the helper each run makes for its body.
A batch is not a name: it is an environment, in a dimension. GameTestBatchFactory groups tests by their TestEnvironmentDefinition holder and their dimension, because that pair is what GameTestBatch is keyed by, and each group is
split into runs of fifty (a default, not a cap — /test verify builds batches of a hundred). One
environment is active at a time on the runner, and moving between batches
tears the old one down and stands the new one up.
The environment interface is an undo log.
TestEnvironmentDefinition.setup returns a value that
TestEnvironmentDefinition.teardown is handed back, and the seven kinds
divide three ways. Five bend one thing about the world and hand back what it was, so teardown puts it back (the weather to its kind, not to its old countdowns): TestEnvironmentDefinition.ClockTime,
TestEnvironmentDefinition.SetDifficulty,
TestEnvironmentDefinition.SetGameRules,
TestEnvironmentDefinition.Timelines and TestEnvironmentDefinition.Weather.
TestEnvironmentDefinition.Functions is the exception with no state to
return — it runs one data-pack function on the way in and another on the way out, either one optional. And TestEnvironmentDefinition.AllOf is the composite: it
returns its children’s activations and unwinds them in reverse.
One test, from a command to a green block
sequenceDiagram
participant TC as TestCommand
participant GTR as GameTestRunner
participant TIB as TestInstance<br/>BlockEntity
participant GTT as GameTestTicker
participant GI as GameTestInfo
participant RGL as ReportGameListener
TC->>GTR: the batches, by environment and dimension
GTR->>GI: prepareTestStructure, for each run
GI->>TIB: placeStructure, then encaseStructure
GTR->>GTR: activate the environment — keep what setup returns
GTR->>GTT: add, for every run whose test block was made
rect rgba(0, 0, 0, 0.04)
Note over GTT,GI: every server tick, from a negative count
GTT->>GI: tick
GI->>GI: startTest at zero — the body runs with a new helper
GI->>GI: succeed, or fail with a GameTestException
GI->>RGL: testPassed or testFailed
RGL->>TIB: setSuccess or setErrorMessage
RGL->>RGL: say, then GlobalTestReporter
end
One test from /test run to its beam — the structure is placed before the environment is set up, the body runs once when a negative tick count reaches zero, and the listener writes the outcome to the block, whose beam is green, red, or orange for an optional test that failed.
Game tests tick on the server thread, from GameTestTicker in one of the last
zones MinecraftServer.tickChildren opens, and only while the tick-rate
manager reports the game running normally — so /tick freeze suspends a
running test where it stands (the server
tick
for the order, and its questions for what a freeze does and does not stop).
The structure comes first, then the environment. For every run in a batch GameTestRunner has its spawner call GameTestInfo.prepareTestStructure, which has
the run’s block entity paste the structure
(TestInstanceBlockEntity.placeStructure) and wall it in with barriers
(TestInstanceBlockEntity.encaseStructure); only then is the batch’s environment activated, and only the runs whose test block was made are handed to the ticker — a run whose paste failed goes too, already carrying its error.
Setup ticks run before tick zero. GameTestInfo.startExecution starts
its counter negative — by the declared setup ticks, plus the spawner’s own
tick delay, plus one — so the body runs when the count reaches zero, in
GameTestInfo.startTest, which hands it a new GameTestHelper.
A sequence catches one kind of exception and only one.
GameTestSequence is the “do this, wait, then assert that”
chain — GameTestSequence.thenExecuteAfter,
GameTestSequence.thenWaitUntil, GameTestSequence.thenSucceed — and it
uses an exception as ordinary control flow, at most one thrown and swallowed
per sequence per tick. What it catches is a GameTestAssertException: an
assertion that has not come true yet is not a failure, so the sequence
swallows it and tries again next tick. A timeout is a different subclass —
GameTestTimeoutException, which GameTestInfo builds and records when the tick count passes the budget, never throwing it — so nothing can swallow it: to GameTestInfo, which records whatever
GameTestException ends the run, a timeout is one more failure.
Reporting is a listener chain, and it writes to up to four places. Chat, the
block, the progress bar and the report. A finished run calls
GameTestListener.testPassed or GameTestListener.testFailed on each of its
listeners, and ReportGameListener answers with
TestInstanceBlockEntity.setSuccess or
TestInstanceBlockEntity.setErrorMessage, which is what colours the beam. ReportGameListener is also what says something in chat, to every player; MultipleTestTracker is the progress bar, which only the headless server prints; and GlobalTestReporter hands the outcome to one of two reporters, LogTestReporter, which logs the failures, or JUnitLikeTestReporter, which writes every result to an XML file.
What a test leaves behind is not undone. The environment has its undo
log; the world does not. A passing test has its barrier shell removed and the non-player entities in and around it discarded, and nothing else: the pasted blocks stay where they were put and the test instance block stays with them — except under /test verify, which clears each finished batch’s space and breaks its test blocks before the next. When its batch finishes, or under /test verify when a test fails, the runner unforces every force-loaded chunk in the level, the test’s and any other’s. A
failing test does not even lose its shell, which is the point — the
failure is left standing so it can be walked into and looked at. Clearing
any of it is a command: /test clearall walks the test instance blocks in
range, clears the space around each, takes the barriers off and breaks the
block, and a test instance block’s own reset re-pastes the structure over
whatever the last run left.
A test with no Java in it
BlockBasedTestInstance runs a test built entirely from TestBlocks inside
the structure. TestBlockMode has four values — start, log, fail and accept
— and the rules are as simple as they sound: exactly one start block emits
redstone to begin, an accept block being triggered is a pass, and a fail
block being triggered is a failure carrying its stored message. That is a
unit test authored in-game with a redstone circuit and shipped as a
structure plus a JSON.
The client half the framework’s package list hides is what makes that
practical: TestInstanceBlockEditScreen and TestBlockEditScreen are how a
test is authored in game, TestInstanceRenderer draws the bounding box, and
GameTestBlockHighlightRenderer is the sole consumer of
ClientboundGameTestHighlightPosPacket. Both serverbound test packets are sent by the client, from those screens: one sets a test block’s fields, and the other sets a test instance block’s test, size and rotation and queries, runs, resets or saves it, from inside the game. TestInstanceBlock is the block itself; TestInstanceBlockEntity, in the cast above, is where the work is.
Three things a running server should know
/test exists on every server, not only in a development environment.
Only TestCommand’s export subcommands are gated on running from an IDE; the rest sit at Commands.LEVEL_GAMEMASTERS (permissions).
How it addresses a test is the data-driven move again: TestFinder hands a subcommand the tests it names, as Registries.TEST_INSTANCE holders where it takes an id selector or asks for the last failures, and as test blocks found by position otherwise, and the
glob in /test run * is ResourceSelectorArgument, the one argument type in
the game that takes one.
Test instance blocks are points of interest. Locating every test within
a 250-block radius is a POI query, not a block scan
(points of interest), which is what makes
/test’s radius subcommands cheap — and what makes a world full of saved
tests carry them in its POI storage.
Underneath both sits a layer with no command of its own.
StructureUtils and StructureGridSpawner clear the space, lay tests out in a grid, transform the far corner and find every test block by position
(jigsaw and templates owns the
template machinery they call). GameTestServer is the third
MinecraftServer subclass — beside the integrated and the dedicated ones
(anatomy) — driven by
GameTestMainUtil, and one thing it changes that matters here is GameTestServer.waitUntilNextTick: it drains tasks instead of sleeping, so a
headless test run goes flat out rather than at twenty ticks a second. It also
installs a no-op gizmo collector (debugging the running
game).
Where to look
GameTestInstance and TestData for what a test is, then GameTestInfo for what happens on a tick, then TestInstanceBlockEntity for what
a test costs the world, and GameTestHelper when you want to write one.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Reference
Verified against Minecraft 26.3 · Reference · The shelf behind the lectures: everything a viewer would pause the video to read, kept where no lecture has to stop for it.
A lecture explains one thing at a time, and it cannot stop to list the 44 entity-data serializers or the ten bits of a flag word without losing the room. The rule for this tier is one question: would a viewer pause the video to read this? If yes, it lives here and the page links to it — so a shelf page is reached from the page that needs it, and the table below is grouped by the one question a reader of a catalogue should ask of it rather than for browsing.
That question is not what does it list but how is it kept, because a catalogue is only as good as the version it was read from. Thirteen of the twenty-three are rewritten by a tool on every deploy, so their rows are re-derived each time, though the sentences each tool types around its tables, and the odd cell it types into a row, are not. The other ten were written by a session reading the classes one at a time, and go stale exactly the way this page’s own last column did.
A shelf is looked up, not watched, so a Reference page is exempt from the rule that every page carries a figure: it draws one only where its subject is a shape — the conversions between coordinate spaces, the ways work crosses a thread boundary — and everywhere else, this page included, the table or the list is the figure.
How each page is kept, and who leans on it
Generated · decompile is read off the source, generated · corpus off the book itself — its pages, and the lane key in its page template — and both are rewritten on every deploy; hand-kept is a session’s reading. The last column’s numerals are the parts in sidebar order, I to XIII, as the lecture map lists them.
| page | what it lists | kept by | the parts whose landing pages point at it |
|---|---|---|---|
| Packets | every packet, by protocol group and direction | generated · decompile | III, V, VI, VII, VIII, IX, X, XIII |
| Registries | every registry key but the root’s: built-in, data-pack, synced | generated · decompile | II, IV, V, VI, VII, IX, XII, XIII |
| Data components | every DataComponentType in DataComponents, persistent or not, and how it crosses the wire | generated · decompile | II, V, VII, VIII, IX |
| Game rules | every rule, type, category, default | generated · decompile | III, IV, V, VI, VIII |
| Attributes | every attribute: default, range, sentiment, syncable | generated · decompile | VI, VIII |
| Entity data serializers | all 44, in registration order, which is the wire id | generated · decompile | VI |
| Enchantment hooks | every public EnchantmentHelper entry point and its callers | generated · decompile | VII |
| Loot context parameter sets | all thirty-one, with required and optional keys | generated · decompile | VII, XIII |
| Entity spawn reasons | all nineteen, and which classes change behaviour for each | generated · decompile | VI |
| Structure spawn overrides | the six structures that replace a biome’s spawn list, and with what | generated · decompile | VI, XII |
| The weapon helpers | the seven Item.Properties helpers and every item built by one | generated · decompile | VII |
| Block update flags | the ten bits of Level.setBlock’s flag word | hand-kept | IV, V |
Damage outside LivingEntity | what each of the twenty-two non-living classes does when hit | hand-kept | VI, VIII |
| What the HUD draws, and when | every HUD element and the condition, if any, it is behind | hand-kept | X |
| Submit phases and feature renderers | the fifteen phases and the twelve renderers, in the order the code lists them | hand-kept | XI |
| Density-function nodes | the forty-four node types, what the compiler turns each into, what range each reports, and which ids the shipped data writes | hand-kept | XII |
| Threads | every thread, who makes it, what may run on it | hand-kept | I, III, IV, VI, IX, X, XI |
| Math and primitives | the coordinate spaces, packings, shapes and random sources | hand-kept | II, IV, V, VI, XII |
| Level data and rules | who owns the seed, spawn, rules and border, and which file each is in | hand-kept | III, IV, VIII, IX, XII |
| Naming drift | the 1.21-era names a reader will reach for, and what 26.3 calls them | hand-kept | I, II, VII, X, XI, XII |
| Glossary | a short entry per term, and the page that owns it | hand-kept | II, V, VI, X, XI, XII, XIII |
| Diagram lanes | every lane abbreviation and the class it means, and the ten that mean a thread, the process, the wire, the disk, a service or the game’s own code instead | generated · corpus | every part |
| Class index | every class a written page backticks or a figure names, and the pages that name it | generated · corpus | — |
The two generated kinds differ in what they are read from.
python tools/gen_reference.py all rewrites eleven of them from the decompile — its declarations, its call sites and its data files — so a version bump re-derives their rows rather than re-reading them; the other two are read off this book instead, by
python tools/verify_names.py --index (the class index, from every written page’s backticked names and every class a figure names) and python tools/check_lanes.py --index (the lanes, from
the key in the book’s page template). Hand-kept means a part session read the classes and wrote the
rows, tools/verify_names.py checks the backticked names on the page, and a
fact-check re-reads the rows — the code’s own orders drift on a version bump, and three of these pages (the HUD, the submit phases, the density-function nodes) are laid out in one.
The last column is one a tool can settle, and it is now settled by one: each of the thirteen parts’ landing pages names the shelf
pages it uses, and tools/check_deps.py re-derives this column from those
thirteen sections and refuses to publish when the two disagree. It disagreed
in six rows on the day the check was written, which is what a hand-kept
column does.
For agents: the whole site is also served as one file at /llms-full.txt.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Packets
Generated from the 26.3 decompile by
tools/gen_reference.py. Do not edit by hand.
Every packet the game defines, by the PacketTypes class that declares it. common, cookie and ping packets are shared by more than one protocol phase; the exact phase→packet bindings are in the five *Protocols classes (GameProtocols, ConfigurationProtocols, LoginProtocols, StatusProtocols, HandshakeProtocols), which bind the shared groups too. See Packets and stream codecs.
| group | clientbound | serverbound |
|---|---|---|
common (CommonPacketTypes) | 14 | 6 |
configuration (ConfigurationPacketTypes) | 6 | 3 |
cookie (CookiePacketTypes) | 1 | 1 |
game (GamePacketTypes) | 129 | 61 |
handshake (HandshakePacketTypes) | 0 | 1 |
login (LoginPacketTypes) | 5 | 4 |
ping (PingPacketTypes) | 1 | 1 |
status (StatusPacketTypes) | 1 | 1 |
| total | 157 | 78 |
common — CommonPacketTypes — shared across phases
| id | direction | class |
|---|---|---|
clear_dialog | clientbound | ClientboundClearDialogPacket |
custom_payload | clientbound | ClientboundCustomPayloadPacket |
custom_report_details | clientbound | ClientboundCustomReportDetailsPacket |
disconnect | clientbound | ClientboundDisconnectPacket |
keep_alive | clientbound | ClientboundKeepAlivePacket |
ping | clientbound | ClientboundPingPacket |
post_effects | clientbound | ClientboundPostEffectsPacket |
resource_pack_pop | clientbound | ClientboundResourcePackPopPacket |
resource_pack_push | clientbound | ClientboundResourcePackPushPacket |
server_links | clientbound | ClientboundServerLinksPacket |
show_dialog | clientbound | ClientboundShowDialogPacket |
store_cookie | clientbound | ClientboundStoreCookiePacket |
transfer | clientbound | ClientboundTransferPacket |
update_tags | clientbound | ClientboundUpdateTagsPacket |
client_information | serverbound | ServerboundClientInformationPacket |
custom_click_action | serverbound | ServerboundCustomClickActionPacket |
custom_payload | serverbound | ServerboundCustomPayloadPacket |
keep_alive | serverbound | ServerboundKeepAlivePacket |
pong | serverbound | ServerboundPongPacket |
resource_pack | serverbound | ServerboundResourcePackPacket |
configuration — ConfigurationPacketTypes
| id | direction | class |
|---|---|---|
code_of_conduct | clientbound | ClientboundCodeOfConductPacket |
finish_configuration | clientbound | ClientboundFinishConfigurationPacket |
registry_data | clientbound | ClientboundRegistryDataPacket |
reset_chat | clientbound | ClientboundResetChatPacket |
select_known_packs | clientbound | ClientboundSelectKnownPacks |
update_enabled_features | clientbound | ClientboundUpdateEnabledFeaturesPacket |
accept_code_of_conduct | serverbound | ServerboundAcceptCodeOfConductPacket |
finish_configuration | serverbound | ServerboundFinishConfigurationPacket |
select_known_packs | serverbound | ServerboundSelectKnownPacks |
cookie — CookiePacketTypes — shared across phases
| id | direction | class |
|---|---|---|
cookie_request | clientbound | ClientboundCookieRequestPacket |
cookie_response | serverbound | ServerboundCookieResponsePacket |
game — GamePacketTypes
| id | direction | class |
|---|---|---|
add_entity | clientbound | ClientboundAddEntityPacket |
add_transient_block | clientbound | ClientboundAddTransientBlockPacket |
animate | clientbound | ClientboundAnimatePacket |
award_stats | clientbound | ClientboundAwardStatsPacket |
block_changed_ack | clientbound | ClientboundBlockChangedAckPacket |
block_destruction | clientbound | ClientboundBlockDestructionPacket |
block_entity_data | clientbound | ClientboundBlockEntityDataPacket |
block_event | clientbound | ClientboundBlockEventPacket |
block_update | clientbound | ClientboundBlockUpdatePacket |
boss_event | clientbound | ClientboundBossEventPacket |
bundle | clientbound | ClientboundBundlePacket |
bundle_delimiter | clientbound | ClientboundBundleDelimiterPacket |
change_difficulty | clientbound | ClientboundChangeDifficultyPacket |
chunk_batch_finished | clientbound | ClientboundChunkBatchFinishedPacket |
chunk_batch_start | clientbound | ClientboundChunkBatchStartPacket |
chunks_biomes | clientbound | ClientboundChunksBiomesPacket |
clear_titles | clientbound | ClientboundClearTitlesPacket |
command_suggestions | clientbound | ClientboundCommandSuggestionsPacket |
commands | clientbound | ClientboundCommandsPacket |
container_close | clientbound | ClientboundContainerClosePacket |
container_set_content | clientbound | ClientboundContainerSetContentPacket |
container_set_data | clientbound | ClientboundContainerSetDataPacket |
container_set_slot | clientbound | ClientboundContainerSetSlotPacket |
cooldown | clientbound | ClientboundCooldownPacket |
custom_chat_completions | clientbound | ClientboundCustomChatCompletionsPacket |
damage_event | clientbound | ClientboundDamageEventPacket |
debug/block_value | clientbound | ClientboundDebugBlockValuePacket |
debug/chunk_value | clientbound | ClientboundDebugChunkValuePacket |
debug/entity_value | clientbound | ClientboundDebugEntityValuePacket |
debug/event | clientbound | ClientboundDebugEventPacket |
debug_sample | clientbound | ClientboundDebugSamplePacket |
delete_chat | clientbound | ClientboundDeleteChatPacket |
disguised_chat | clientbound | ClientboundDisguisedChatPacket |
entity_event | clientbound | ClientboundEntityEventPacket |
entity_position_sync | clientbound | ClientboundEntityPositionSyncPacket |
explode | clientbound | ClientboundExplodePacket |
forget_level_chunk | clientbound | ClientboundForgetLevelChunkPacket |
game_event | clientbound | ClientboundGameEventPacket |
game_rule_values | clientbound | ClientboundGameRuleValuesPacket |
game_test_highlight_pos | clientbound | ClientboundGameTestHighlightPosPacket |
hurt_animation | clientbound | ClientboundHurtAnimationPacket |
initialize_border | clientbound | ClientboundInitializeBorderPacket |
level_chunk_with_light | clientbound | ClientboundLevelChunkWithLightPacket |
level_event | clientbound | ClientboundLevelEventPacket |
level_particles | clientbound | ClientboundLevelParticlesPacket |
light_update | clientbound | ClientboundLightUpdatePacket |
login | clientbound | ClientboundLoginPacket |
low_disk_space_warning | clientbound | ClientboundLowDiskSpaceWarningPacket |
map_item_data | clientbound | ClientboundMapItemDataPacket |
merchant_offers | clientbound | ClientboundMerchantOffersPacket |
mount_screen_open | clientbound | ClientboundMountScreenOpenPacket |
move_entity_pos | clientbound | ClientboundMoveEntityPacket.Pos |
move_entity_pos_rot | clientbound | ClientboundMoveEntityPacket.PosRot |
move_entity_rot | clientbound | ClientboundMoveEntityPacket.Rot |
move_minecart_along_track | clientbound | ClientboundMoveMinecartPacket |
move_vehicle | clientbound | ClientboundMoveVehiclePacket |
open_book | clientbound | ClientboundOpenBookPacket |
open_screen | clientbound | ClientboundOpenScreenPacket |
open_sign_editor | clientbound | ClientboundOpenSignEditorPacket |
place_ghost_recipe | clientbound | ClientboundPlaceGhostRecipePacket |
player_abilities | clientbound | ClientboundPlayerAbilitiesPacket |
player_chat | clientbound | ClientboundPlayerChatPacket |
player_combat_end | clientbound | ClientboundPlayerCombatEndPacket |
player_combat_enter | clientbound | ClientboundPlayerCombatEnterPacket |
player_combat_kill | clientbound | ClientboundPlayerCombatKillPacket |
player_info_remove | clientbound | ClientboundPlayerInfoRemovePacket |
player_info_update | clientbound | ClientboundPlayerInfoUpdatePacket |
player_look_at | clientbound | ClientboundPlayerLookAtPacket |
player_position | clientbound | ClientboundPlayerPositionPacket |
player_rotation | clientbound | ClientboundPlayerRotationPacket |
projectile_power | clientbound | ClientboundProjectilePowerPacket |
recipe_book_add | clientbound | ClientboundRecipeBookAddPacket |
recipe_book_remove | clientbound | ClientboundRecipeBookRemovePacket |
recipe_book_settings | clientbound | ClientboundRecipeBookSettingsPacket |
remove_entities | clientbound | ClientboundRemoveEntitiesPacket |
remove_mob_effect | clientbound | ClientboundRemoveMobEffectPacket |
reset_score | clientbound | ClientboundResetScorePacket |
respawn | clientbound | ClientboundRespawnPacket |
rotate_head | clientbound | ClientboundRotateHeadPacket |
section_blocks_update | clientbound | ClientboundSectionBlocksUpdatePacket |
select_advancements_tab | clientbound | ClientboundSelectAdvancementsTabPacket |
server_data | clientbound | ClientboundServerDataPacket |
set_action_bar_text | clientbound | ClientboundSetActionBarTextPacket |
set_border_center | clientbound | ClientboundSetBorderCenterPacket |
set_border_lerp_size | clientbound | ClientboundSetBorderLerpSizePacket |
set_border_size | clientbound | ClientboundSetBorderSizePacket |
set_border_warning_delay | clientbound | ClientboundSetBorderWarningDelayPacket |
set_border_warning_distance | clientbound | ClientboundSetBorderWarningDistancePacket |
set_camera | clientbound | ClientboundSetCameraPacket |
set_chunk_cache_center | clientbound | ClientboundSetChunkCacheCenterPacket |
set_chunk_cache_radius | clientbound | ClientboundSetChunkCacheRadiusPacket |
set_cursor_item | clientbound | ClientboundSetCursorItemPacket |
set_default_spawn_position | clientbound | ClientboundSetDefaultSpawnPositionPacket |
set_display_objective | clientbound | ClientboundSetDisplayObjectivePacket |
set_entity_data | clientbound | ClientboundSetEntityDataPacket |
set_entity_link | clientbound | ClientboundSetEntityLinkPacket |
set_entity_motion | clientbound | ClientboundSetEntityMotionPacket |
set_equipment | clientbound | ClientboundSetEquipmentPacket |
set_experience | clientbound | ClientboundSetExperiencePacket |
set_health | clientbound | ClientboundSetHealthPacket |
set_held_slot | clientbound | ClientboundSetHeldSlotPacket |
set_objective | clientbound | ClientboundSetObjectivePacket |
set_passengers | clientbound | ClientboundSetPassengersPacket |
set_player_inventory | clientbound | ClientboundSetPlayerInventoryPacket |
set_player_team | clientbound | ClientboundSetPlayerTeamPacket |
set_score | clientbound | ClientboundSetScorePacket |
set_simulation_distance | clientbound | ClientboundSetSimulationDistancePacket |
set_subtitle_text | clientbound | ClientboundSetSubtitleTextPacket |
set_time | clientbound | ClientboundSetTimePacket |
set_title_text | clientbound | ClientboundSetTitleTextPacket |
set_titles_animation | clientbound | ClientboundSetTitlesAnimationPacket |
sound | clientbound | ClientboundSoundPacket |
sound_entity | clientbound | ClientboundSoundEntityPacket |
start_configuration | clientbound | ClientboundStartConfigurationPacket |
stop_sound | clientbound | ClientboundStopSoundPacket |
swing_animation | clientbound | ClientboundSwingAnimationPacket |
system_chat | clientbound | ClientboundSystemChatPacket |
tab_list | clientbound | ClientboundTabListPacket |
tag_query | clientbound | ClientboundTagQueryPacket |
take_item_entity | clientbound | ClientboundTakeItemEntityPacket |
teleport_entity | clientbound | ClientboundTeleportEntityPacket |
test_instance_block_status | clientbound | ClientboundTestInstanceBlockStatus |
ticking_state | clientbound | ClientboundTickingStatePacket |
ticking_step | clientbound | ClientboundTickingStepPacket |
update_advancements | clientbound | ClientboundUpdateAdvancementsPacket |
update_attributes | clientbound | ClientboundUpdateAttributesPacket |
update_mob_effect | clientbound | ClientboundUpdateMobEffectPacket |
update_recipes | clientbound | ClientboundUpdateRecipesPacket |
waypoint | clientbound | ClientboundTrackedWaypointPacket |
accept_teleportation | serverbound | ServerboundAcceptTeleportationPacket |
attack | serverbound | ServerboundAttackPacket |
block_entity_tag_query | serverbound | ServerboundBlockEntityTagQueryPacket |
bundle_item_selected | serverbound | ServerboundSelectBundleItemPacket |
change_difficulty | serverbound | ServerboundChangeDifficultyPacket |
change_game_mode | serverbound | ServerboundChangeGameModePacket |
chat | serverbound | ServerboundChatPacket |
chat_ack | serverbound | ServerboundChatAckPacket |
chat_command | serverbound | ServerboundChatCommandPacket |
chat_command_signed | serverbound | ServerboundChatCommandSignedPacket |
chat_session_update | serverbound | ServerboundChatSessionUpdatePacket |
chunk_batch_received | serverbound | ServerboundChunkBatchReceivedPacket |
client_command | serverbound | ServerboundClientCommandPacket |
client_tick_end | serverbound | ServerboundClientTickEndPacket |
command_suggestion | serverbound | ServerboundCommandSuggestionPacket |
configuration_acknowledged | serverbound | ServerboundConfigurationAcknowledgedPacket |
container_button_click | serverbound | ServerboundContainerButtonClickPacket |
container_click | serverbound | ServerboundContainerClickPacket |
container_close | serverbound | ServerboundContainerClosePacket |
container_slot_state_changed | serverbound | ServerboundContainerSlotStateChangedPacket |
debug_subscription_request | serverbound | ServerboundDebugSubscriptionRequestPacket |
edit_book | serverbound | ServerboundEditBookPacket |
entity_tag_query | serverbound | ServerboundEntityTagQueryPacket |
interact | serverbound | ServerboundInteractPacket |
jigsaw_generate | serverbound | ServerboundJigsawGeneratePacket |
lock_difficulty | serverbound | ServerboundLockDifficultyPacket |
move_player_pos | serverbound | ServerboundMovePlayerPacket.Pos |
move_player_pos_rot | serverbound | ServerboundMovePlayerPacket.PosRot |
move_player_rot | serverbound | ServerboundMovePlayerPacket.Rot |
move_player_status_only | serverbound | ServerboundMovePlayerPacket.StatusOnly |
move_vehicle | serverbound | ServerboundMoveVehiclePacket |
paddle_boat | serverbound | ServerboundPaddleBoatPacket |
pick_item_from_block | serverbound | ServerboundPickItemFromBlockPacket |
pick_item_from_entity | serverbound | ServerboundPickItemFromEntityPacket |
place_recipe | serverbound | ServerboundPlaceRecipePacket |
player_abilities | serverbound | ServerboundPlayerAbilitiesPacket |
player_action | serverbound | ServerboundPlayerActionPacket |
player_command | serverbound | ServerboundPlayerCommandPacket |
player_input | serverbound | ServerboundPlayerInputPacket |
player_loaded | serverbound | ServerboundPlayerLoadedPacket |
punch | serverbound | ServerboundPunchPacket |
recipe_book_change_settings | serverbound | ServerboundRecipeBookChangeSettingsPacket |
recipe_book_seen_recipe | serverbound | ServerboundRecipeBookSeenRecipePacket |
rename_item | serverbound | ServerboundRenameItemPacket |
seen_advancements | serverbound | ServerboundSeenAdvancementsPacket |
select_trade | serverbound | ServerboundSelectTradePacket |
set_beacon | serverbound | ServerboundSetBeaconPacket |
set_carried_item | serverbound | ServerboundSetCarriedItemPacket |
set_command_block | serverbound | ServerboundSetCommandBlockPacket |
set_command_minecart | serverbound | ServerboundSetCommandMinecartPacket |
set_creative_mode_slot | serverbound | ServerboundSetCreativeModeSlotPacket |
set_game_rule | serverbound | ServerboundSetGameRulePacket |
set_jigsaw_block | serverbound | ServerboundSetJigsawBlockPacket |
set_structure_block | serverbound | ServerboundSetStructureBlockPacket |
set_test_block | serverbound | ServerboundSetTestBlockPacket |
sign_update | serverbound | ServerboundSignUpdatePacket |
spectator_action | serverbound | ServerboundSpectatorActionPacket |
teleport_to_entity | serverbound | ServerboundTeleportToEntityPacket |
test_instance_block_action | serverbound | ServerboundTestInstanceBlockActionPacket |
use_item | serverbound | ServerboundUseItemPacket |
use_item_on | serverbound | ServerboundUseItemOnPacket |
handshake — HandshakePacketTypes
| id | direction | class |
|---|---|---|
intention | serverbound | ClientIntentionPacket |
login — LoginPacketTypes
| id | direction | class |
|---|---|---|
custom_query | clientbound | ClientboundCustomQueryPacket |
hello | clientbound | ClientboundHelloPacket |
login_compression | clientbound | ClientboundLoginCompressionPacket |
login_disconnect | clientbound | ClientboundLoginDisconnectPacket |
login_finished | clientbound | ClientboundLoginFinishedPacket |
custom_query_answer | serverbound | ServerboundCustomQueryAnswerPacket |
hello | serverbound | ServerboundHelloPacket |
key | serverbound | ServerboundKeyPacket |
login_acknowledged | serverbound | ServerboundLoginAcknowledgedPacket |
ping — PingPacketTypes — shared across phases
| id | direction | class |
|---|---|---|
pong_response | clientbound | ClientboundPongResponsePacket |
ping_request | serverbound | ServerboundPingRequestPacket |
status — StatusPacketTypes
| id | direction | class |
|---|---|---|
status_response | clientbound | ClientboundStatusResponsePacket |
status_request | serverbound | ServerboundStatusRequestPacket |
Registries
Generated from the 26.3 decompile by
tools/gen_reference.py. Do not edit by hand.
Every registry key in the game but the root registry’s own. 156 of them are declared in Registries; five more are declared by the class that owns them (ClockTimeMarkers, EquipmentAssets, RecipePropertySet, ServerFunctionLibrary, WaypointStyleAssets) with the public ResourceKey.createRegistryKey rather than Registries’ private helper, which is why this total is larger than the 156 identifiers and registries counts. Built-in registries are created empty when BuiltInRegistries loads, filled during Bootstrap.bootStrap — most by BuiltInRegistries.bootStrap through their owners’ own bootstrap methods, the block, item and entity-type registries earlier, by their owner classes’ static initialisers, which Bootstrap.bootStrap reaches first — and frozen; data-pack registries are loaded per world by RegistryDataLoader from JSON (WORLD_REGISTRIES, or DIMENSION_REGISTRIES for level stems), and the reloadable ones by the same loader on every data-pack reload (RELOADABLE_REGISTRIES, read by ReloadableServerRegistries); synced ones are sent to the client in the configuration phase (SYNCHRONIZED_REGISTRIES). A key that is none of these is a registry type the game reasons about without a global instance (e.g. per-world or client-side). See Identifiers and registries.
161 keys · 95 built-in · 60 data-pack, 8 of them reloadable · 32 synced
| key | element type | kind | synced |
|---|---|---|---|
activity (Registries.ACTIVITY) | Activity | built-in | |
advancement (Registries.ADVANCEMENT) | Advancement | data-pack (reloadable) | |
attribute (Registries.ATTRIBUTE) | Attribute | built-in | |
attribute_type (Registries.ATTRIBUTE_TYPE) | AttributeType<…> | built-in | |
banner_pattern (Registries.BANNER_PATTERN) | BannerPattern | data-pack | yes |
block (Registries.BLOCK) | Block | built-in | |
block_entity_type (Registries.BLOCK_ENTITY_TYPE) | BlockEntityType<…> | built-in | |
block_predicate_type (Registries.BLOCK_PREDICATE_TYPE) | BlockPredicateType<…> | built-in | |
block_transformer (Registries.BLOCK_TRANSFORMER) | BlockTransformer | data-pack | yes |
cat_sound_variant (Registries.CAT_SOUND_VARIANT) | CatSoundVariant | data-pack | yes |
cat_variant (Registries.CAT_VARIANT) | CatVariant | data-pack | yes |
chat_type (Registries.CHAT_TYPE) | ChatType | data-pack | yes |
chicken_sound_variant (Registries.CHICKEN_SOUND_VARIANT) | ChickenSoundVariant | data-pack | yes |
chicken_variant (Registries.CHICKEN_VARIANT) | ChickenVariant | data-pack | yes |
chunk_status (Registries.CHUNK_STATUS) | ChunkStatus | built-in | |
clock_time_marker (ClockTimeMarkers.ROOT_ID) | ClockTimeMarker | — | |
command_argument_type (Registries.COMMAND_ARGUMENT_TYPE) | ArgumentTypeInfo<…> | built-in | |
consume_effect_type (Registries.CONSUME_EFFECT_TYPE) | ConsumeEffect.Type<…> | built-in | |
context_float_provider (Registries.CONTEXT_FLOAT_PROVIDER) | ContextFloatProvider | data-pack (reloadable) | |
context_float_provider_type (Registries.CONTEXT_FLOAT_PROVIDER_TYPE) | MapCodec<…> | built-in | |
context_int_provider (Registries.CONTEXT_INT_PROVIDER) | ContextIntProvider | data-pack (reloadable) | |
context_int_provider_type (Registries.CONTEXT_INT_PROVIDER_TYPE) | MapCodec<…> | built-in | |
context_key_set (Registries.CONTEXT_KEY_SET) | ContextKeySet | built-in | |
cow_sound_variant (Registries.COW_SOUND_VARIANT) | CowSoundVariant | data-pack | yes |
cow_variant (Registries.COW_VARIANT) | CowVariant | data-pack | yes |
creative_mode_tab (Registries.CREATIVE_MODE_TAB) | CreativeModeTab | built-in | |
custom_stat (Registries.CUSTOM_STAT) | Identifier | built-in | |
damage_type (Registries.DAMAGE_TYPE) | DamageType | data-pack | yes |
data_component_predicate_type (Registries.DATA_COMPONENT_PREDICATE_TYPE) | DataComponentPredicate.Type<…> | built-in | |
data_component_type (Registries.DATA_COMPONENT_TYPE) | DataComponentType<…> | built-in | |
debug_subscription (Registries.DEBUG_SUBSCRIPTION) | DebugSubscription<…> | built-in | |
decorated_pot_pattern (Registries.DECORATED_POT_PATTERN) | DecoratedPotPattern | data-pack | yes |
dialog (Registries.DIALOG) | Dialog | data-pack | yes |
dialog_action_type (Registries.DIALOG_ACTION_TYPE) | MapCodec<…> | built-in | |
dialog_body_type (Registries.DIALOG_BODY_TYPE) | MapCodec<…> | built-in | |
dialog_type (Registries.DIALOG_TYPE) | MapCodec<…> | built-in | |
dimension (Registries.DIMENSION) | Level | — | |
dimension (Registries.LEVEL_STEM) | LevelStem | data-pack (dimension) | |
dimension_type (Registries.DIMENSION_TYPE) | DimensionType | data-pack | yes |
enchantment (Registries.ENCHANTMENT) | Enchantment | data-pack | yes |
enchantment_effect_component_type (Registries.ENCHANTMENT_EFFECT_COMPONENT_TYPE) | DataComponentType<…> | built-in | |
enchantment_entity_effect_type (Registries.ENCHANTMENT_ENTITY_EFFECT_TYPE) | MapCodec<…> | built-in | |
enchantment_level_based_value_type (Registries.ENCHANTMENT_LEVEL_BASED_VALUE_TYPE) | MapCodec<…> | built-in | |
enchantment_location_based_effect_type (Registries.ENCHANTMENT_LOCATION_BASED_EFFECT_TYPE) | MapCodec<…> | built-in | |
enchantment_provider (Registries.ENCHANTMENT_PROVIDER) | EnchantmentProvider | data-pack | |
enchantment_provider_type (Registries.ENCHANTMENT_PROVIDER_TYPE) | MapCodec<…> | built-in | |
enchantment_value_effect_type (Registries.ENCHANTMENT_VALUE_EFFECT_TYPE) | MapCodec<…> | built-in | |
entity_sub_predicate_type (Registries.ENTITY_SUB_PREDICATE_TYPE) | Codec<…> | built-in | |
entity_type (Registries.ENTITY_TYPE) | EntityType<…> | built-in | |
environment_attribute (Registries.ENVIRONMENT_ATTRIBUTE) | EnvironmentAttribute<…> | built-in | |
equipment_asset (EquipmentAssets.ROOT_ID) | EquipmentAsset | — | |
float_provider_type (Registries.FLOAT_PROVIDER_TYPE) | MapCodec<…> | built-in | |
fluid (Registries.FLUID) | Fluid | built-in | |
frog_variant (Registries.FROG_VARIANT) | FrogVariant | data-pack | yes |
function (ServerFunctionLibrary.TYPE_KEY) | CommandFunction<…> | — | |
game_event (Registries.GAME_EVENT) | GameEvent | built-in | |
game_rule (Registries.GAME_RULE) | GameRule<…> | built-in | |
height_provider_type (Registries.HEIGHT_PROVIDER_TYPE) | HeightProviderType<…> | built-in | |
incoming_rpc_methods (Registries.INCOMING_RPC_METHOD) | IncomingRpcMethod<…> | built-in | |
input_control_type (Registries.INPUT_CONTROL_TYPE) | MapCodec<…> | built-in | |
instrument (Registries.INSTRUMENT) | Instrument | data-pack | yes |
int_provider_type (Registries.INT_PROVIDER_TYPE) | MapCodec<…> | built-in | |
item (Registries.ITEM) | Item | built-in | |
item_modifier (Registries.ITEM_MODIFIER) | LootItemFunction | data-pack (reloadable) | |
jukebox_song (Registries.JUKEBOX_SONG) | JukeboxSong | data-pack | yes |
loot_condition_type (Registries.LOOT_CONDITION_TYPE) | MapCodec<…> | built-in | |
loot_function_type (Registries.LOOT_FUNCTION_TYPE) | MapCodec<…> | built-in | |
loot_nbt_provider_type (Registries.LOOT_NBT_PROVIDER_TYPE) | MapCodec<…> | built-in | |
loot_pool_entry_type (Registries.LOOT_POOL_ENTRY_TYPE) | MapCodec<…> | built-in | |
loot_score_provider_type (Registries.LOOT_SCORE_PROVIDER_TYPE) | MapCodec<…> | built-in | |
loot_table (Registries.LOOT_TABLE) | LootTable | data-pack (reloadable) | |
map_decoration_type (Registries.MAP_DECORATION_TYPE) | MapDecorationType | built-in | |
memory_module_type (Registries.MEMORY_MODULE_TYPE) | MemoryModuleType<…> | built-in | |
menu (Registries.MENU) | MenuType<…> | built-in | |
mob_effect (Registries.MOB_EFFECT) | MobEffect | built-in | |
number_format_type (Registries.NUMBER_FORMAT_TYPE) | NumberFormatType<…> | built-in | |
outgoing_rpc_methods (Registries.OUTGOING_RPC_METHOD) | OutgoingRpcMethod<…> | built-in | |
painting_variant (Registries.PAINTING_VARIANT) | PaintingVariant | data-pack | yes |
particle_type (Registries.PARTICLE_TYPE) | ParticleType<…> | built-in | |
permission_check_type (Registries.PERMISSION_CHECK_TYPE) | MapCodec<…> | built-in | |
permission_type (Registries.PERMISSION_TYPE) | MapCodec<…> | built-in | |
pig_sound_variant (Registries.PIG_SOUND_VARIANT) | PigSoundVariant | data-pack | yes |
pig_variant (Registries.PIG_VARIANT) | PigVariant | data-pack | yes |
point_of_interest_type (Registries.POINT_OF_INTEREST_TYPE) | PoiType | built-in | |
pos_rule_test (Registries.POS_RULE_TEST) | PosRuleTestType<…> | built-in | |
position_source_type (Registries.POSITION_SOURCE_TYPE) | PositionSourceType<…> | built-in | |
potion (Registries.POTION) | Potion | built-in | |
predicate (Registries.PREDICATE) | LootItemCondition | data-pack (reloadable) | |
recipe (Registries.RECIPE) | Recipe<…> | data-pack (reloadable) | |
recipe_book_category (Registries.RECIPE_BOOK_CATEGORY) | RecipeBookCategory | built-in | |
recipe_display (Registries.RECIPE_DISPLAY) | RecipeDisplay.Type<…> | built-in | |
recipe_property_set (RecipePropertySet.TYPE_KEY) | RecipePropertySet | — | |
recipe_serializer (Registries.RECIPE_SERIALIZER) | RecipeSerializer<…> | built-in | |
recipe_type (Registries.RECIPE_TYPE) | RecipeType<…> | built-in | |
rule_block_entity_modifier (Registries.RULE_BLOCK_ENTITY_MODIFIER) | RuleBlockEntityModifierType<…> | built-in | |
rule_test (Registries.RULE_TEST) | RuleTestType<…> | built-in | |
sensor_type (Registries.SENSOR_TYPE) | SensorType<…> | built-in | |
slot_display (Registries.SLOT_DISPLAY) | SlotDisplay.Type<…> | built-in | |
slot_source (Registries.SLOT_SOURCE) | SlotSource | data-pack (reloadable) | |
slot_source_type (Registries.SLOT_SOURCE_TYPE) | MapCodec<…> | built-in | |
sound_event (Registries.SOUND_EVENT) | SoundEvent | built-in | |
spawn_condition_type (Registries.SPAWN_CONDITION_TYPE) | MapCodec<…> | built-in | |
stat_type (Registries.STAT_TYPE) | StatType<…> | built-in | |
sulfur_cube_archetype (Registries.SULFUR_CUBE_ARCHETYPE) | SulfurCubeArchetype | data-pack | yes |
test_environment (Registries.TEST_ENVIRONMENT) | TestEnvironmentDefinition<…> | data-pack | yes |
test_environment_definition_type (Registries.TEST_ENVIRONMENT_DEFINITION_TYPE) | MapCodec<…> | built-in | |
test_function (Registries.TEST_FUNCTION) | Consumer<…> | built-in | |
test_instance (Registries.TEST_INSTANCE) | GameTestInstance | data-pack | yes |
test_instance_type (Registries.TEST_INSTANCE_TYPE) | MapCodec<…> | built-in | |
ticket_type (Registries.TICKET_TYPE) | TicketType | built-in | |
timeline (Registries.TIMELINE) | Timeline | data-pack | yes |
trade_set (Registries.TRADE_SET) | TradeSet | data-pack | |
trial_spawner (Registries.TRIAL_SPAWNER_CONFIG) | TrialSpawnerConfig | data-pack | |
trigger_type (Registries.TRIGGER_TYPE) | CriterionTrigger<…> | built-in | |
trim_material (Registries.TRIM_MATERIAL) | TrimMaterial | data-pack | yes |
trim_pattern (Registries.TRIM_PATTERN) | TrimPattern | data-pack | yes |
villager_profession (Registries.VILLAGER_PROFESSION) | VillagerProfession | built-in | |
villager_trade (Registries.VILLAGER_TRADE) | VillagerTrade | data-pack | |
villager_type (Registries.VILLAGER_TYPE) | VillagerType | built-in | |
waypoint_style_asset (WaypointStyleAssets.ROOT_ID) | WaypointStyleAsset | — | |
wolf_sound_variant (Registries.WOLF_SOUND_VARIANT) | WolfSoundVariant | data-pack | yes |
wolf_variant (Registries.WOLF_VARIANT) | WolfVariant | data-pack | yes |
world_clock (Registries.WORLD_CLOCK) | WorldClock | data-pack | yes |
worldgen/biome (Registries.BIOME) | Biome | data-pack | yes |
worldgen/biome_source (Registries.BIOME_SOURCE) | MapCodec<…> | built-in | |
worldgen/block_state_provider (Registries.BLOCK_STATE_PROVIDER) | BlockStateProvider | data-pack | yes |
worldgen/block_state_provider_type (Registries.BLOCK_STATE_PROVIDER_TYPE) | MapCodec<…> | built-in | |
worldgen/carver (Registries.CARVER) | WorldCarver | data-pack | |
worldgen/carver_type (Registries.CARVER_TYPE) | MapCodec<…> | built-in | |
worldgen/chunk_generator (Registries.CHUNK_GENERATOR) | MapCodec<…> | built-in | |
worldgen/density_function (Registries.DENSITY_FUNCTION) | DensityFunction | data-pack | |
worldgen/density_function_type (Registries.DENSITY_FUNCTION_TYPE) | MapCodec<…> | built-in | |
worldgen/feature (Registries.FEATURE) | Feature | data-pack | |
worldgen/feature_size_type (Registries.FEATURE_SIZE_TYPE) | FeatureSizeType<…> | built-in | |
worldgen/feature_type (Registries.FEATURE_TYPE) | MapCodec<…> | built-in | |
worldgen/flat_level_generator_preset (Registries.FLAT_LEVEL_GENERATOR_PRESET) | FlatLevelGeneratorPreset | data-pack | |
worldgen/foliage_placer_type (Registries.FOLIAGE_PLACER_TYPE) | FoliagePlacerType<…> | built-in | |
worldgen/material_condition (Registries.MATERIAL_CONDITION) | MaterialCondition | data-pack | |
worldgen/material_condition_type (Registries.MATERIAL_CONDITION_TYPE) | MapCodec<…> | built-in | |
worldgen/material_rule (Registries.MATERIAL_RULE) | MaterialRule | data-pack | |
worldgen/material_rule_type (Registries.MATERIAL_RULE_TYPE) | MapCodec<…> | built-in | |
worldgen/multi_noise_biome_source_parameter_list (Registries.MULTI_NOISE_BIOME_SOURCE_PARAMETER_LIST) | MultiNoiseBiomeSourceParameterList | data-pack | |
worldgen/noise (Registries.NOISE) | NormalNoise | data-pack | |
worldgen/noise_settings (Registries.NOISE_SETTINGS) | NoiseGeneratorSettings | data-pack | |
worldgen/placed_feature (Registries.PLACED_FEATURE) | PlacedFeature | data-pack | |
worldgen/placement_modifier_type (Registries.PLACEMENT_MODIFIER_TYPE) | MapCodec<…> | built-in | |
worldgen/pool_alias_binding (Registries.POOL_ALIAS_BINDING) | MapCodec<…> | built-in | |
worldgen/processor_list (Registries.PROCESSOR_LIST) | StructureProcessorList | data-pack | |
worldgen/root_placer_type (Registries.ROOT_PLACER_TYPE) | RootPlacerType<…> | built-in | |
worldgen/structure (Registries.STRUCTURE) | Structure | data-pack | |
worldgen/structure_piece (Registries.STRUCTURE_PIECE) | StructurePieceType | built-in | |
worldgen/structure_placement (Registries.STRUCTURE_PLACEMENT) | MapCodec<…> | built-in | |
worldgen/structure_pool_element (Registries.STRUCTURE_POOL_ELEMENT) | StructurePoolElementType<…> | built-in | |
worldgen/structure_processor (Registries.STRUCTURE_PROCESSOR) | MapCodec<…> | built-in | |
worldgen/structure_set (Registries.STRUCTURE_SET) | StructureSet | data-pack | |
worldgen/structure_type (Registries.STRUCTURE_TYPE) | StructureType<…> | built-in | |
worldgen/template_pool (Registries.TEMPLATE_POOL) | StructureTemplatePool | data-pack | |
worldgen/tree_decorator_type (Registries.TREE_DECORATOR_TYPE) | TreeDecoratorType<…> | built-in | |
worldgen/trunk_placer_type (Registries.TRUNK_PLACER_TYPE) | TrunkPlacerType<…> | built-in | |
worldgen/world_preset (Registries.WORLD_PRESET) | WorldPreset | data-pack | |
zombie_nautilus_variant (Registries.ZOMBIE_NAUTILUS_VARIANT) | ZombieNautilusVariant | data-pack | yes |
Data components
Generated from the 26.3 decompile by
tools/gen_reference.py. Do not edit by hand.
Every DataComponentType registered in DataComponents. Persistent types have a Codec and are written to disk; a type without one is transient (DataComponentType.isTransient), never saved but sent like the rest. Every type crosses the wire: the on the wire column says whether it declares its own StreamCodec or is sent as NBT through the one DataComponentType.Builder.build derives from its Codec, and a type with neither cannot be built. Cached persistent codecs share one EncoderCache. See Data components.
122 components
| id | value type | persistent | on the wire |
|---|---|---|---|
custom_data (DataComponents.CUSTOM_DATA) | CustomData | yes | NBT, through its Codec |
max_stack_size (DataComponents.MAX_STACK_SIZE) | Integer | yes | its own StreamCodec |
max_damage (DataComponents.MAX_DAMAGE) | Integer | yes | its own StreamCodec |
damage (DataComponents.DAMAGE) | Integer | yes | its own StreamCodec |
unbreakable (DataComponents.UNBREAKABLE) | Unit | yes | its own StreamCodec |
use_effects (DataComponents.USE_EFFECTS) | UseEffects | yes | its own StreamCodec |
custom_name (DataComponents.CUSTOM_NAME) | Component | yes (cached) | its own StreamCodec |
minimum_attack_charge (DataComponents.MINIMUM_ATTACK_CHARGE) | Float | yes | its own StreamCodec |
damage_type (DataComponents.DAMAGE_TYPE) | Holder<…> | yes | its own StreamCodec |
item_name (DataComponents.ITEM_NAME) | Component | yes (cached) | its own StreamCodec |
item_model (DataComponents.ITEM_MODEL) | Identifier | yes (cached) | its own StreamCodec |
lore (DataComponents.LORE) | ItemLore | yes (cached) | its own StreamCodec |
rarity (DataComponents.RARITY) | Rarity | yes | its own StreamCodec |
enchantments (DataComponents.ENCHANTMENTS) | ItemEnchantments | yes (cached) | its own StreamCodec |
can_place_on (DataComponents.CAN_PLACE_ON) | AdventureModePredicate | yes (cached) | its own StreamCodec |
can_break (DataComponents.CAN_BREAK) | AdventureModePredicate | yes (cached) | its own StreamCodec |
attribute_modifiers (DataComponents.ATTRIBUTE_MODIFIERS) | ItemAttributeModifiers | yes (cached) | its own StreamCodec |
custom_model_data (DataComponents.CUSTOM_MODEL_DATA) | CustomModelData | yes | its own StreamCodec |
tooltip_display (DataComponents.TOOLTIP_DISPLAY) | TooltipDisplay | yes (cached) | its own StreamCodec |
repair_cost (DataComponents.REPAIR_COST) | Integer | yes | its own StreamCodec |
creative_slot_lock (DataComponents.CREATIVE_SLOT_LOCK) | Unit | its own StreamCodec | |
enchantment_glint_override (DataComponents.ENCHANTMENT_GLINT_OVERRIDE) | Boolean | yes | its own StreamCodec |
intangible_projectile (DataComponents.INTANGIBLE_PROJECTILE) | Unit | yes | NBT, through its Codec |
food (DataComponents.FOOD) | FoodProperties | yes (cached) | its own StreamCodec |
consumable (DataComponents.CONSUMABLE) | Consumable | yes (cached) | its own StreamCodec |
use_remainder (DataComponents.USE_REMAINDER) | UseRemainder | yes (cached) | its own StreamCodec |
use_cooldown (DataComponents.USE_COOLDOWN) | UseCooldown | yes (cached) | its own StreamCodec |
damage_resistant (DataComponents.DAMAGE_RESISTANT) | DamageResistant | yes (cached) | its own StreamCodec |
tool (DataComponents.TOOL) | Tool | yes (cached) | its own StreamCodec |
weapon (DataComponents.WEAPON) | Weapon | yes (cached) | its own StreamCodec |
attack_range (DataComponents.ATTACK_RANGE) | AttackRange | yes (cached) | its own StreamCodec |
enchantable (DataComponents.ENCHANTABLE) | Enchantable | yes (cached) | its own StreamCodec |
equippable (DataComponents.EQUIPPABLE) | Equippable | yes (cached) | its own StreamCodec |
repairable (DataComponents.REPAIRABLE) | Repairable | yes (cached) | its own StreamCodec |
glider (DataComponents.GLIDER) | Unit | yes | its own StreamCodec |
tooltip_style (DataComponents.TOOLTIP_STYLE) | Identifier | yes (cached) | its own StreamCodec |
death_protection (DataComponents.DEATH_PROTECTION) | DeathProtection | yes (cached) | its own StreamCodec |
blocks_attacks (DataComponents.BLOCKS_ATTACKS) | BlocksAttacks | yes (cached) | its own StreamCodec |
piercing_weapon (DataComponents.PIERCING_WEAPON) | PiercingWeapon | yes (cached) | its own StreamCodec |
kinetic_weapon (DataComponents.KINETIC_WEAPON) | KineticWeapon | yes (cached) | its own StreamCodec |
attack_animation (DataComponents.ATTACK_ANIMATION) | SwingAnimation | yes | its own StreamCodec |
interact_animation (DataComponents.INTERACT_ANIMATION) | SwingAnimation | yes | its own StreamCodec |
additional_trade_cost (DataComponents.ADDITIONAL_TRADE_COST) | Integer | its own StreamCodec | |
block_transformer (DataComponents.BLOCK_TRANSFORMER) | Holder<…> | yes (cached) | its own StreamCodec |
villager_food (DataComponents.VILLAGER_FOOD) | VillagerFood | yes | its own StreamCodec |
stored_enchantments (DataComponents.STORED_ENCHANTMENTS) | ItemEnchantments | yes (cached) | its own StreamCodec |
dye (DataComponents.DYE) | DyeColor | yes | its own StreamCodec |
dyed_color (DataComponents.DYED_COLOR) | DyedItemColor | yes | its own StreamCodec |
map_id (DataComponents.MAP_ID) | MapId | yes | its own StreamCodec |
map_decorations (DataComponents.MAP_DECORATIONS) | MapDecorations | yes (cached) | NBT, through its Codec |
map_post_processing (DataComponents.MAP_POST_PROCESSING) | MapPostProcessing | its own StreamCodec | |
charged_projectiles (DataComponents.CHARGED_PROJECTILES) | ChargedProjectiles | yes (cached) | its own StreamCodec |
bundle_contents (DataComponents.BUNDLE_CONTENTS) | BundleContents | yes (cached) | its own StreamCodec |
potion_contents (DataComponents.POTION_CONTENTS) | PotionContents | yes (cached) | its own StreamCodec |
potion_duration_scale (DataComponents.POTION_DURATION_SCALE) | Float | yes (cached) | its own StreamCodec |
suspicious_stew_effects (DataComponents.SUSPICIOUS_STEW_EFFECTS) | SuspiciousStewEffects | yes (cached) | its own StreamCodec |
writable_book_content (DataComponents.WRITABLE_BOOK_CONTENT) | WritableBookContent | yes (cached) | its own StreamCodec |
written_book_content (DataComponents.WRITTEN_BOOK_CONTENT) | WrittenBookContent | yes (cached) | its own StreamCodec |
trim (DataComponents.TRIM) | ArmorTrim | yes (cached) | its own StreamCodec |
debug_stick_state (DataComponents.DEBUG_STICK_STATE) | DebugStickState | yes (cached) | NBT, through its Codec |
entity_data (DataComponents.ENTITY_DATA) | TypedEntityData<…> | yes | its own StreamCodec |
bucket_entity_data (DataComponents.BUCKET_ENTITY_DATA) | CustomData | yes | its own StreamCodec |
block_entity_data (DataComponents.BLOCK_ENTITY_DATA) | TypedEntityData<…> | yes | its own StreamCodec |
instrument (DataComponents.INSTRUMENT) | InstrumentComponent | yes (cached) | its own StreamCodec |
provides_trim_material (DataComponents.PROVIDES_TRIM_MATERIAL) | Holder<…> | yes (cached) | its own StreamCodec |
ominous_bottle_amplifier (DataComponents.OMINOUS_BOTTLE_AMPLIFIER) | OminousBottleAmplifier | yes | its own StreamCodec |
jukebox_playable (DataComponents.JUKEBOX_PLAYABLE) | JukeboxPlayable | yes | its own StreamCodec |
provides_banner_patterns (DataComponents.PROVIDES_BANNER_PATTERNS) | HolderSet<…> | yes (cached) | its own StreamCodec |
recipes (DataComponents.RECIPES) | List<…> | yes (cached) | NBT, through its Codec |
lodestone_tracker (DataComponents.LODESTONE_TRACKER) | LodestoneTracker | yes (cached) | its own StreamCodec |
firework_explosion (DataComponents.FIREWORK_EXPLOSION) | FireworkExplosion | yes (cached) | its own StreamCodec |
fireworks (DataComponents.FIREWORKS) | Fireworks | yes (cached) | its own StreamCodec |
profile (DataComponents.PROFILE) | ResolvableProfile | yes (cached) | its own StreamCodec |
note_block_sound (DataComponents.NOTE_BLOCK_SOUND) | Identifier | yes | its own StreamCodec |
banner_patterns (DataComponents.BANNER_PATTERNS) | BannerPatternLayers | yes (cached) | its own StreamCodec |
base_color (DataComponents.BASE_COLOR) | DyeColor | yes | its own StreamCodec |
pot_decorations (DataComponents.POT_DECORATIONS) | PotDecorations | yes (cached) | its own StreamCodec |
container (DataComponents.CONTAINER) | ItemContainerContents | yes (cached) | its own StreamCodec |
block_state (DataComponents.BLOCK_STATE) | BlockItemStateProperties | yes (cached) | its own StreamCodec |
bees (DataComponents.BEES) | Bees | yes (cached) | its own StreamCodec |
sulfur_cube_content (DataComponents.SULFUR_CUBE_CONTENT) | SulfurCubeContent | yes (cached) | its own StreamCodec |
lock (DataComponents.LOCK) | LockCode | yes | NBT, through its Codec |
container_loot (DataComponents.CONTAINER_LOOT) | SeededContainerLoot | yes | NBT, through its Codec |
break_sound (DataComponents.BREAK_SOUND) | Holder<…> | yes (cached) | its own StreamCodec |
compostable (DataComponents.COMPOSTABLE) | Compostable | yes | its own StreamCodec |
cooking_fuel (DataComponents.COOKING_FUEL) | CookingFuel | yes | its own StreamCodec |
brewing_fuel (DataComponents.BREWING_FUEL) | BrewingFuel | yes | its own StreamCodec |
mob_visibility (DataComponents.MOB_VISIBILITY) | MobVisibility | yes | its own StreamCodec |
villager/variant (DataComponents.VILLAGER_VARIANT) | Holder<…> | yes | its own StreamCodec |
wolf/variant (DataComponents.WOLF_VARIANT) | Holder<…> | yes | its own StreamCodec |
wolf/sound_variant (DataComponents.WOLF_SOUND_VARIANT) | Holder<…> | yes | its own StreamCodec |
wolf/collar (DataComponents.WOLF_COLLAR) | DyeColor | yes | its own StreamCodec |
fox/variant (DataComponents.FOX_VARIANT) | Fox.Variant | yes | its own StreamCodec |
salmon/size (DataComponents.SALMON_SIZE) | Salmon.Variant | yes | its own StreamCodec |
parrot/variant (DataComponents.PARROT_VARIANT) | Parrot.Variant | yes | its own StreamCodec |
tropical_fish/pattern (DataComponents.TROPICAL_FISH_PATTERN) | TropicalFish.Pattern | yes | its own StreamCodec |
tropical_fish/base_color (DataComponents.TROPICAL_FISH_BASE_COLOR) | DyeColor | yes | its own StreamCodec |
tropical_fish/pattern_color (DataComponents.TROPICAL_FISH_PATTERN_COLOR) | DyeColor | yes | its own StreamCodec |
mooshroom/variant (DataComponents.MOOSHROOM_VARIANT) | MushroomCow.Variant | yes | its own StreamCodec |
rabbit/variant (DataComponents.RABBIT_VARIANT) | Rabbit.Variant | yes | its own StreamCodec |
pig/variant (DataComponents.PIG_VARIANT) | Holder<…> | yes | its own StreamCodec |
pig/sound_variant (DataComponents.PIG_SOUND_VARIANT) | Holder<…> | yes | its own StreamCodec |
cow/variant (DataComponents.COW_VARIANT) | Holder<…> | yes | its own StreamCodec |
cow/sound_variant (DataComponents.COW_SOUND_VARIANT) | Holder<…> | yes | its own StreamCodec |
chicken/variant (DataComponents.CHICKEN_VARIANT) | Holder<…> | yes | its own StreamCodec |
chicken/sound_variant (DataComponents.CHICKEN_SOUND_VARIANT) | Holder<…> | yes | its own StreamCodec |
zombie_nautilus/variant (DataComponents.ZOMBIE_NAUTILUS_VARIANT) | Holder<…> | yes | its own StreamCodec |
frog/variant (DataComponents.FROG_VARIANT) | Holder<…> | yes | its own StreamCodec |
horse/variant (DataComponents.HORSE_VARIANT) | Variant | yes | its own StreamCodec |
painting/variant (DataComponents.PAINTING_VARIANT) | Holder<…> | yes | its own StreamCodec |
llama/variant (DataComponents.LLAMA_VARIANT) | Llama.Variant | yes | its own StreamCodec |
axolotl/variant (DataComponents.AXOLOTL_VARIANT) | Axolotl.Variant | yes | its own StreamCodec |
cat/variant (DataComponents.CAT_VARIANT) | Holder<…> | yes | its own StreamCodec |
cat/sound_variant (DataComponents.CAT_SOUND_VARIANT) | Holder<…> | yes | its own StreamCodec |
cat/collar (DataComponents.CAT_COLLAR) | DyeColor | yes | its own StreamCodec |
sheep/color (DataComponents.SHEEP_COLOR) | DyeColor | yes | its own StreamCodec |
shulker/color (DataComponents.SHULKER_COLOR) | DyeColor | yes | its own StreamCodec |
provides_pottery_pattern (DataComponents.PROVIDES_POTTERY_PATTERN) | Holder<…> | yes | its own StreamCodec |
sign_text_front (DataComponents.SIGN_TEXT_FRONT) | SignText | yes (cached) | its own StreamCodec |
sign_text_back (DataComponents.SIGN_TEXT_BACK) | SignText | yes (cached) | its own StreamCodec |
waxed (DataComponents.WAXED) | Unit | yes | its own StreamCodec |
cushion/color (DataComponents.CUSHION_COLOR) | DyeColor | yes | its own StreamCodec |
Game rules
Generated from the 26.3 decompile by
tools/gen_reference.py. Do not edit by hand.
Every rule declared in GameRules, with its category (GameRuleCategory) and default. Integer rules list the bounds they declare after the default — a minimum always, a maximum only where one is not Integer.MAX_VALUE — and any feature gate. Values live in a GameRuleMap — a SavedData at data/minecraft/game_rules.dat, one set for the whole server rather than one per level. See Level data and rules.
59 rules
| rule | type | category | default |
|---|---|---|---|
command_block_output (GameRules.COMMAND_BLOCK_OUTPUT) | Boolean | chat | true |
log_admin_commands (GameRules.LOG_ADMIN_COMMANDS) | Boolean | chat | true |
send_command_feedback (GameRules.SEND_COMMAND_FEEDBACK) | Boolean | chat | true |
show_advancement_messages (GameRules.SHOW_ADVANCEMENT_MESSAGES) | Boolean | chat | true |
show_death_messages (GameRules.SHOW_DEATH_MESSAGES) | Boolean | chat | true |
block_drops (GameRules.BLOCK_DROPS) | Boolean | drops | true |
block_explosion_drop_decay (GameRules.BLOCK_EXPLOSION_DROP_DECAY) | Boolean | drops | true |
entity_drops (GameRules.ENTITY_DROPS) | Boolean | drops | true |
mob_drops (GameRules.MOB_DROPS) | Boolean | drops | true |
mob_explosion_drop_decay (GameRules.MOB_EXPLOSION_DROP_DECAY) | Boolean | drops | true |
projectiles_can_break_blocks (GameRules.PROJECTILES_CAN_BREAK_BLOCKS) | Boolean | drops | true |
tnt_explosion_drop_decay (GameRules.TNT_EXPLOSION_DROP_DECAY) | Boolean | drops | false |
allow_entering_nether_using_portals (GameRules.ALLOW_ENTERING_NETHER_USING_PORTALS) | Boolean | misc | true |
command_blocks_work (GameRules.COMMAND_BLOCKS_WORK) | Boolean | misc | true |
global_sound_events (GameRules.GLOBAL_SOUND_EVENTS) | Boolean | misc | true |
max_block_modifications (GameRules.MAX_BLOCK_MODIFICATIONS) | Integer | misc | 32768 (min 1) |
max_command_forks (GameRules.MAX_COMMAND_FORKS) | Integer | misc | 65536 (min 0) |
max_command_sequence_length (GameRules.MAX_COMMAND_SEQUENCE_LENGTH) | Integer | misc | 65536 (min 0) |
max_minecart_speed (GameRules.MAX_MINECART_SPEED) | Integer | misc | 8 (min 1, max 1000, requires FeatureFlags.MINECART_IMPROVEMENTS) |
reduced_debug_info (GameRules.REDUCED_DEBUG_INFO) | Boolean | misc | false |
spawner_blocks_work (GameRules.SPAWNER_BLOCKS_WORK) | Boolean | misc | true |
tnt_explodes (GameRules.TNT_EXPLODES) | Boolean | misc | true |
forgive_dead_players (GameRules.FORGIVE_DEAD_PLAYERS) | Boolean | mobs | true |
max_entity_cramming (GameRules.MAX_ENTITY_CRAMMING) | Integer | mobs | 24 (min 0) |
mob_griefing (GameRules.MOB_GRIEFING) | Boolean | mobs | true |
raids (GameRules.RAIDS) | Boolean | mobs | true |
universal_anger (GameRules.UNIVERSAL_ANGER) | Boolean | mobs | false |
drowning_damage (GameRules.DROWNING_DAMAGE) | Boolean | player | true |
elytra_movement_check (GameRules.ELYTRA_MOVEMENT_CHECK) | Boolean | player | true |
ender_pearls_vanish_on_death (GameRules.ENDER_PEARLS_VANISH_ON_DEATH) | Boolean | player | true |
fall_damage (GameRules.FALL_DAMAGE) | Boolean | player | true |
fire_damage (GameRules.FIRE_DAMAGE) | Boolean | player | true |
freeze_damage (GameRules.FREEZE_DAMAGE) | Boolean | player | true |
immediate_respawn (GameRules.IMMEDIATE_RESPAWN) | Boolean | player | false |
keep_inventory (GameRules.KEEP_INVENTORY) | Boolean | player | false |
limited_crafting (GameRules.LIMITED_CRAFTING) | Boolean | player | false |
locator_bar (GameRules.LOCATOR_BAR) | Boolean | player | true |
natural_health_regeneration (GameRules.NATURAL_HEALTH_REGENERATION) | Boolean | player | true |
player_movement_check (GameRules.PLAYER_MOVEMENT_CHECK) | Boolean | player | true |
players_nether_portal_creative_delay (GameRules.PLAYERS_NETHER_PORTAL_CREATIVE_DELAY) | Integer | player | 0 (min 0) |
players_nether_portal_default_delay (GameRules.PLAYERS_NETHER_PORTAL_DEFAULT_DELAY) | Integer | player | 80 (min 0) |
players_sleeping_percentage (GameRules.PLAYERS_SLEEPING_PERCENTAGE) | Integer | player | 100 (min 0) |
pvp (GameRules.PVP) | Boolean | player | true |
respawn_radius (GameRules.RESPAWN_RADIUS) | Integer | player | 10 (min 0) |
spectators_generate_chunks (GameRules.SPECTATORS_GENERATE_CHUNKS) | Boolean | player | true |
spawn_mobs (GameRules.SPAWN_MOBS) | Boolean | spawning | true |
spawn_monsters (GameRules.SPAWN_MONSTERS) | Boolean | spawning | true |
spawn_patrols (GameRules.SPAWN_PATROLS) | Boolean | spawning | true |
spawn_phantoms (GameRules.SPAWN_PHANTOMS) | Boolean | spawning | true |
spawn_wandering_traders (GameRules.SPAWN_WANDERING_TRADERS) | Boolean | spawning | true |
spawn_wardens (GameRules.SPAWN_WARDENS) | Boolean | spawning | true |
advance_time (GameRules.ADVANCE_TIME) | Boolean | updates | true |
advance_weather (GameRules.ADVANCE_WEATHER) | Boolean | updates | true |
fire_spread_radius_around_player (GameRules.FIRE_SPREAD_RADIUS_AROUND_PLAYER) | Integer | updates | 128 (min -1) |
lava_source_conversion (GameRules.LAVA_SOURCE_CONVERSION) | Boolean | updates | false |
max_snow_accumulation_height (GameRules.MAX_SNOW_ACCUMULATION_HEIGHT) | Integer | updates | 1 (min 0, max 8) |
random_tick_speed (GameRules.RANDOM_TICK_SPEED) | Integer | updates | 3 (min 0) |
spread_vines (GameRules.SPREAD_VINES) | Boolean | updates | true |
water_source_conversion (GameRules.WATER_SOURCE_CONVERSION) | Boolean | updates | true |
Attributes
Generated from the 26.3 decompile by
tools/gen_reference.py. Do not edit by hand.
Every attribute registered in Attributes. All of them are RangedAttributes, so every one clamps to its range once, at the end of AttributeInstance.calculateValue. Syncable attributes are the only ones ClientboundUpdateAttributesPacket ever carries: a mutation to one of the others changes the server’s number and never reaches the client at all. The sentiment decides tooltip colour and nothing else. Defaults here are the registry’s, and most entity types override them in their own AttributeSupplier. See Attributes.
40 attributes, 32 syncable and 8 not
| id | constant | default | min | max | syncable | sentiment |
|---|---|---|---|---|---|---|
air_drag_modifier | Attributes.AIR_DRAG_MODIFIER | 1 | 0 | 2048 | yes | positive |
armor | Attributes.ARMOR | 0 | 0 | 30 | yes | positive |
armor_toughness | Attributes.ARMOR_TOUGHNESS | 0 | 0 | 20 | yes | positive |
attack_damage | Attributes.ATTACK_DAMAGE | 2 | 0 | 2048 | positive | |
attack_knockback | Attributes.ATTACK_KNOCKBACK | 0 | 0 | 5 | positive | |
attack_speed | Attributes.ATTACK_SPEED | 4 | 0 | 1024 | yes | positive |
below_name_distance | Attributes.BELOW_NAME_DISTANCE | 10 | 0 | 512 | yes | positive |
block_break_speed | Attributes.BLOCK_BREAK_SPEED | 1 | 0 | 1024 | yes | positive |
block_interaction_range | Attributes.BLOCK_INTERACTION_RANGE | 4.5 | 0 | 64 | yes | positive |
bounciness | Attributes.BOUNCINESS | 0 | 0 | 1 | yes | positive |
burning_time | Attributes.BURNING_TIME | 1 | 0 | 1024 | yes | negative |
camera_distance | Attributes.CAMERA_DISTANCE | 4 | 0 | 32 | yes | positive |
entity_interaction_range | Attributes.ENTITY_INTERACTION_RANGE | 3 | 0 | 64 | yes | positive |
explosion_knockback_resistance | Attributes.EXPLOSION_KNOCKBACK_RESISTANCE | 0 | 0 | 1 | yes | positive |
fall_damage_multiplier | Attributes.FALL_DAMAGE_MULTIPLIER | 1 | 0 | 100 | yes | negative |
flying_speed | Attributes.FLYING_SPEED | 0.4 | 0 | 1024 | yes | positive |
follow_range | Attributes.FOLLOW_RANGE | 32 | 0 | 2048 | positive | |
friction_modifier | Attributes.FRICTION_MODIFIER | 1 | 0 | 2048 | yes | positive |
gravity | Attributes.GRAVITY | 0.08 | -1 | 1 | yes | neutral |
jump_strength | Attributes.JUMP_STRENGTH | 0.42 | 0 | 32 | yes | positive |
knockback_resistance | Attributes.KNOCKBACK_RESISTANCE | 0 | -2 | 1 | positive | |
luck | Attributes.LUCK | 0 | -1024 | 1024 | yes | positive |
max_absorption | Attributes.MAX_ABSORPTION | 0 | 0 | 2048 | yes | positive |
max_health | Attributes.MAX_HEALTH | 20 | 1 | 1024 | yes | positive |
mining_efficiency | Attributes.MINING_EFFICIENCY | 0 | 0 | 1024 | yes | positive |
movement_efficiency | Attributes.MOVEMENT_EFFICIENCY | 0 | 0 | 1 | yes | positive |
movement_speed | Attributes.MOVEMENT_SPEED | 0.7 | 0 | 1024 | yes | positive |
name_tag_distance | Attributes.NAME_TAG_DISTANCE | 64 | 0 | 512 | yes | positive |
oxygen_bonus | Attributes.OXYGEN_BONUS | 0 | 0 | 1024 | yes | positive |
safe_fall_distance | Attributes.SAFE_FALL_DISTANCE | 3 | -1024 | 1024 | yes | positive |
scale | Attributes.SCALE | 1 | 0.0625 | 16 | yes | neutral |
sneaking_speed | Attributes.SNEAKING_SPEED | 0.3 | 0 | 1 | yes | positive |
spawn_reinforcements | Attributes.SPAWN_REINFORCEMENTS_CHANCE | 0 | 0 | 1 | positive | |
step_height | Attributes.STEP_HEIGHT | 0.6 | 0 | 10 | yes | positive |
submerged_mining_speed | Attributes.SUBMERGED_MINING_SPEED | 0.2 | 0 | 20 | yes | positive |
sweeping_damage_ratio | Attributes.SWEEPING_DAMAGE_RATIO | 0 | 0 | 1 | yes | positive |
tempt_range | Attributes.TEMPT_RANGE | 10 | 0 | 2048 | positive | |
water_movement_efficiency | Attributes.WATER_MOVEMENT_EFFICIENCY | 0 | 0 | 1 | yes | positive |
waypoint_receive_range | Attributes.WAYPOINT_RECEIVE_RANGE | 0 | 0 | 60000000 | neutral | |
waypoint_transmit_range | Attributes.WAYPOINT_TRANSMIT_RANGE | 0 | 0 | 60000000 | neutral |
Entity data serializers
Generated from the 26.3 decompile by
tools/gen_reference.py. Do not edit by hand.
Every EntityDataSerializer in EntityDataSerializers, in registration order, which is the wire id — EntityDataSerializers.registerSerializer pushes each one into a CrudeIncrementalIntIdentityHashBiMap that hands out the next int. A SynchedEntityData.DataValue on the wire is an unsigned byte accessor id, this var-int, and the encoded value. For value type marks the ones built by EntityDataSerializer.forValueType, the immutable case where EntityDataSerializer.copy is identity. See Synched entity data.
44 serializers, wire ids 0 to 43
| id | constant | value type | built by |
|---|---|---|---|
| 0 | EntityDataSerializers.BYTE | Byte | for value type |
| 1 | EntityDataSerializers.INT | Integer | for value type |
| 2 | EntityDataSerializers.LONG | Long | for value type |
| 3 | EntityDataSerializers.FLOAT | Float | for value type |
| 4 | EntityDataSerializers.STRING | String | for value type |
| 5 | EntityDataSerializers.COMPONENT | Component | for value type |
| 6 | EntityDataSerializers.OPTIONAL_COMPONENT | Optional<Component> | for value type |
| 7 | EntityDataSerializers.ITEM_STACK | ItemStack | an anonymous subclass |
| 8 | EntityDataSerializers.BOOLEAN | Boolean | for value type |
| 9 | EntityDataSerializers.ROTATIONS | Rotations | for value type |
| 10 | EntityDataSerializers.BLOCK_POS | BlockPos | for value type |
| 11 | EntityDataSerializers.OPTIONAL_BLOCK_POS | Optional<BlockPos> | for value type |
| 12 | EntityDataSerializers.DIRECTION | Direction | for value type |
| 13 | EntityDataSerializers.OPTIONAL_LIVING_ENTITY_REFERENCE | Optional<EntityReference<LivingEntity>> | for value type |
| 14 | EntityDataSerializers.BLOCK_STATE | BlockState | for value type |
| 15 | EntityDataSerializers.OPTIONAL_BLOCK_STATE | Optional<BlockState> | for value type |
| 16 | EntityDataSerializers.PARTICLE | ParticleOptions | for value type |
| 17 | EntityDataSerializers.PARTICLES | List<ParticleOptions> | for value type |
| 18 | EntityDataSerializers.VILLAGER_DATA | VillagerData | for value type |
| 19 | EntityDataSerializers.OPTIONAL_UNSIGNED_INT | OptionalInt | for value type |
| 20 | EntityDataSerializers.POSE | Pose | for value type |
| 21 | EntityDataSerializers.CAT_VARIANT | Holder<CatVariant> | for value type |
| 22 | EntityDataSerializers.CAT_SOUND_VARIANT | Holder<CatSoundVariant> | for value type |
| 23 | EntityDataSerializers.COW_VARIANT | Holder<CowVariant> | for value type |
| 24 | EntityDataSerializers.COW_SOUND_VARIANT | Holder<CowSoundVariant> | for value type |
| 25 | EntityDataSerializers.WOLF_VARIANT | Holder<WolfVariant> | for value type |
| 26 | EntityDataSerializers.WOLF_SOUND_VARIANT | Holder<WolfSoundVariant> | for value type |
| 27 | EntityDataSerializers.FROG_VARIANT | Holder<FrogVariant> | for value type |
| 28 | EntityDataSerializers.PIG_VARIANT | Holder<PigVariant> | for value type |
| 29 | EntityDataSerializers.PIG_SOUND_VARIANT | Holder<PigSoundVariant> | for value type |
| 30 | EntityDataSerializers.CHICKEN_VARIANT | Holder<ChickenVariant> | for value type |
| 31 | EntityDataSerializers.CHICKEN_SOUND_VARIANT | Holder<ChickenSoundVariant> | for value type |
| 32 | EntityDataSerializers.ZOMBIE_NAUTILUS_VARIANT | Holder<ZombieNautilusVariant> | for value type |
| 33 | EntityDataSerializers.OPTIONAL_GLOBAL_POS | Optional<GlobalPos> | for value type |
| 34 | EntityDataSerializers.PAINTING_VARIANT | Holder<PaintingVariant> | for value type |
| 35 | EntityDataSerializers.SNIFFER_STATE | Sniffer.State | for value type |
| 36 | EntityDataSerializers.ARMADILLO_STATE | Armadillo.ArmadilloState | for value type |
| 37 | EntityDataSerializers.COPPER_GOLEM_STATE | CopperGolemState | for value type |
| 38 | EntityDataSerializers.WEATHERING_COPPER_STATE | WeatheringCopper.WeatherState | for value type |
| 39 | EntityDataSerializers.VECTOR3 | Vector3fc | for value type |
| 40 | EntityDataSerializers.QUATERNION | Quaternionfc | for value type |
| 41 | EntityDataSerializers.RESOLVABLE_PROFILE | ResolvableProfile | for value type |
| 42 | EntityDataSerializers.HUMANOID_ARM | HumanoidArm | for value type |
| 43 | EntityDataSerializers.DYE_COLOR | DyeColor | for value type |
Enchantment hooks
Generated from the 26.3 decompile by
tools/gen_reference.py. Do not edit by hand.
Every public entry point of EnchantmentHelper, with the classes that call it. 60 classes call it, so this table is where most of the rest of the game meets the enchantment system: most rows are a moment at which some other system asks whether an enchantment wants to change what happens, and the rest store, look up or choose enchantments. Callers are the declaring files, one per class, excluding EnchantmentHelper itself. See Enchantments for what an enchantment is and Enchanting for the selection half.
50 entry points, 47 of them called from outside the class
| entry point | overloads | called from |
|---|---|---|
EnchantmentHelper.canStoreEnchantments | 1 | AnvilMenu |
EnchantmentHelper.createBook | 1 | CreativeModeTabs |
EnchantmentHelper.doPostAttackEffects | 1 | AbstractCubeMob, AbstractWindCharge, Bee, ChargeAttack, EnderDragon, EvokerFangs, HoglinBase, IronGolem, LargeFireball, LivingEntity, LlamaSpit, Mob, Player, RamTarget, ShulkerBullet, SmallFireball, WitherSkull |
EnchantmentHelper.doPostAttackEffectsWithItemSource | 1 | AbstractArrow, Player |
EnchantmentHelper.doPostAttackEffectsWithItemSourceOnBreak | 1 | ThrownTrident |
EnchantmentHelper.doPostPiercingAttackEffects | 1 | LivingEntity |
EnchantmentHelper.enchantItem | 2 | EnchantWithLevelsFunction |
EnchantmentHelper.enchantItemFromProvider | 1 | Enderman, Mob, Pillager, SkeletonTrapGoal, Vindicator |
EnchantmentHelper.filterCompatibleEnchantments | 1 | nothing outside the class |
EnchantmentHelper.forEachModifier | 2 | ItemStack |
EnchantmentHelper.getAvailableEnchantmentResults | 1 | nothing outside the class |
EnchantmentHelper.getDamageProtection | 1 | LivingEntity |
EnchantmentHelper.getEnchantmentCost | 1 | EnchantmentMenu |
EnchantmentHelper.getEnchantmentLevel | 1 | EnchantedCountIncreaseFunction, LootItemRandomChanceWithEnchantedBonusCondition |
EnchantmentHelper.getEnchantmentsForCrafting | 1 | AnvilMenu, EnchantCommand, GrindstoneMenu, RepairItemRecipe |
EnchantmentHelper.getFishingLuckBonus | 1 | FishingRodItem |
EnchantmentHelper.getFishingTimeReduction | 1 | FishingRodItem |
EnchantmentHelper.getHighestLevel | 1 | nothing outside the class |
EnchantmentHelper.getItemEnchantmentLevel | 1 | ApplyBonusCount, BonusLevelTableCondition |
EnchantmentHelper.getPiercingCount | 1 | AbstractArrow |
EnchantmentHelper.getRandomItemWith | 1 | ExperienceOrb |
EnchantmentHelper.getTridentReturnToOwnerAcceleration | 1 | ThrownTrident |
EnchantmentHelper.getTridentSpinAttackStrength | 1 | TridentItem |
EnchantmentHelper.has | 1 | AbstractHorse, Allay, ArmorSlot, ArmorStand, Equippable, Mob, Piglin, Player, ZombieVillager |
EnchantmentHelper.hasAnyEnchantments | 1 | GrindstoneMenu |
EnchantmentHelper.hasTag | 1 | BeehiveBlock, DecoratedPotBlock, IceBlock, InfestedBlock |
EnchantmentHelper.isEnchantmentCompatible | 1 | EnchantCommand |
EnchantmentHelper.isImmuneToDamage | 1 | LivingEntity |
EnchantmentHelper.modifyArmorEffectiveness | 1 | CombatRules |
EnchantmentHelper.modifyCrossbowChargingTime | 1 | CrossbowItem |
EnchantmentHelper.modifyDamage | 1 | AbstractArrow, LivingEntity, Mob, ServerPlayer, ThrownTrident |
EnchantmentHelper.modifyDurabilityToRepairFromXp | 1 | ExperienceOrb |
EnchantmentHelper.modifyFallBasedDamage | 1 | MaceItem |
EnchantmentHelper.modifyKnockback | 1 | AbstractArrow, LivingEntity |
EnchantmentHelper.onHitBlock | 1 | AbstractArrow, ServerPlayerGameMode, ThrownTrident |
EnchantmentHelper.onProjectileSpawned | 1 | Projectile |
EnchantmentHelper.pickHighestLevel | 1 | CrossbowItem, TridentItem |
EnchantmentHelper.processAmmoUse | 1 | ProjectileWeaponItem |
EnchantmentHelper.processBlockExperience | 1 | Block |
EnchantmentHelper.processDurabilityChange | 1 | ItemStack |
EnchantmentHelper.processEquipmentDropChance | 1 | Mob |
EnchantmentHelper.processMobExperience | 1 | LivingEntity |
EnchantmentHelper.processProjectileCount | 1 | ProjectileWeaponItem |
EnchantmentHelper.processProjectileSpread | 1 | ProjectileWeaponItem |
EnchantmentHelper.runLocationChangedEffects | 2 | LivingEntity, ServerPlayer |
EnchantmentHelper.selectEnchantment | 1 | EnchantmentMenu, EnchantmentsByCost, EnchantmentsByCostWithDifficulty |
EnchantmentHelper.setEnchantments | 1 | AnvilMenu |
EnchantmentHelper.stopLocationBasedEffects | 2 | LivingEntity, ServerPlayer |
EnchantmentHelper.tickEffects | 1 | LivingEntity |
EnchantmentHelper.updateEnchantments | 1 | GrindstoneMenu, ItemStack, RepairItemRecipe, SetEnchantmentsFunction |
Loot context parameter sets
Generated from the 26.3 decompile by
tools/gen_reference.py. Do not edit by hand.
Every ContextKeySet registered in LootContextParamSets, with the keys its ContextKeySet.Builder declared. The set a roll is checked against belongs to the caller: ContextMap.Builder.buildAndValidate throws both on a required key that is absent and on a key the set does not declare at all, so this table is the contract each call site has to satisfy. A loot table names a set of its own, its type, but that one is checked only when the table loads. A loot element reads a key with LootContext.getOptional, which answers null for a key that is not there, or asks only whether it is there, with LootContext.hasParameter. Seventeen of these thirty-one sets never roll a LootTable at all — the engine is wider than the loot package. See Contexts and predicates.
31 parameter sets
| set | id | required | optional |
|---|---|---|---|
LootContextParamSets.ADVANCEMENT_ENTITY | advancement_entity | LootContextParams.THIS_ENTITY, LootContextParams.ORIGIN | — |
LootContextParamSets.ADVANCEMENT_LOCATION | advancement_location | LootContextParams.THIS_ENTITY, LootContextParams.ORIGIN, LootContextParams.TOOL, LootContextParams.BLOCK_STATE | LootContextParams.BLOCK_ENTITY |
LootContextParamSets.ADVANCEMENT_REWARD | advancement_reward | LootContextParams.THIS_ENTITY, LootContextParams.ORIGIN | — |
LootContextParamSets.ARCHAEOLOGY | archaeology | LootContextParams.ORIGIN, LootContextParams.THIS_ENTITY, LootContextParams.TOOL | — |
LootContextParamSets.PIGLIN_BARTER | barter | LootContextParams.THIS_ENTITY | — |
LootContextParamSets.BLOCK | block | LootContextParams.BLOCK_STATE, LootContextParams.ORIGIN, LootContextParams.TOOL | LootContextParams.THIS_ENTITY, LootContextParams.BLOCK_ENTITY, LootContextParams.EXPLOSION_RADIUS |
LootContextParamSets.BLOCK_INTERACT | block_interact | LootContextParams.BLOCK_STATE, LootContextParams.ORIGIN | LootContextParams.BLOCK_ENTITY, LootContextParams.INTERACTING_ENTITY, LootContextParams.TOOL |
LootContextParamSets.BLOCK_USE | block_use | LootContextParams.THIS_ENTITY, LootContextParams.ORIGIN, LootContextParams.BLOCK_STATE | — |
LootContextParamSets.CHEST | chest | LootContextParams.ORIGIN | LootContextParams.THIS_ENTITY |
LootContextParamSets.COMMAND | command | LootContextParams.ORIGIN | LootContextParams.THIS_ENTITY |
LootContextParamSets.COMMAND_COMPUTE_DEFAULT | command_compute_default | LootContextParams.ORIGIN | LootContextParams.THIS_ENTITY |
LootContextParamSets.COMMAND_COMPUTE_ENTITY | command_compute_entity | LootContextParams.ORIGIN, LootContextParams.TARGET_ENTITY | LootContextParams.THIS_ENTITY |
LootContextParamSets.COMMAND_COMPUTE_POSITION | command_compute_position | LootContextParams.ORIGIN, LootContextParams.BLOCK_STATE | LootContextParams.THIS_ENTITY, LootContextParams.BLOCK_ENTITY |
LootContextParamSets.COMMAND_SLOT_SOURCE | command_slot_source | LootContextParams.ORIGIN, LootContextParams.CONTAINER | LootContextParams.THIS_ENTITY |
LootContextParamSets.CONTAINER_PROCESS | container_process | LootContextParams.BLOCK_ENTITY, LootContextParams.BLOCK_STATE, LootContextParams.CONTAINER, LootContextParams.ORIGIN | — |
LootContextParamSets.EMPTY | empty | — | — |
LootContextParamSets.ENCHANTED_DAMAGE | enchanted_damage | LootContextParams.THIS_ENTITY, LootContextParams.ENCHANTMENT_LEVEL, LootContextParams.ORIGIN, LootContextParams.DAMAGE_SOURCE | LootContextParams.DIRECT_ATTACKING_ENTITY, LootContextParams.ATTACKING_ENTITY |
LootContextParamSets.ENCHANTED_ENTITY | enchanted_entity | LootContextParams.THIS_ENTITY, LootContextParams.ENCHANTMENT_LEVEL, LootContextParams.ORIGIN | — |
LootContextParamSets.ENCHANTED_ITEM | enchanted_item | LootContextParams.TOOL, LootContextParams.ENCHANTMENT_LEVEL | — |
LootContextParamSets.ENCHANTED_LOCATION | enchanted_location | LootContextParams.THIS_ENTITY, LootContextParams.ENCHANTMENT_LEVEL, LootContextParams.ORIGIN, LootContextParams.ENCHANTMENT_ACTIVE | — |
LootContextParamSets.ENTITY | entity | LootContextParams.THIS_ENTITY, LootContextParams.ORIGIN, LootContextParams.DAMAGE_SOURCE | LootContextParams.ATTACKING_ENTITY, LootContextParams.DIRECT_ATTACKING_ENTITY, LootContextParams.LAST_DAMAGE_PLAYER |
LootContextParamSets.ENTITY_INTERACT | entity_interact | LootContextParams.TARGET_ENTITY, LootContextParams.TOOL | LootContextParams.INTERACTING_ENTITY |
LootContextParamSets.EQUIPMENT | equipment | LootContextParams.ORIGIN, LootContextParams.THIS_ENTITY | — |
LootContextParamSets.FISHING | fishing | LootContextParams.ORIGIN, LootContextParams.TOOL | LootContextParams.THIS_ENTITY |
LootContextParamSets.ALL_PARAMS | generic | LootContextParams.THIS_ENTITY, LootContextParams.LAST_DAMAGE_PLAYER, LootContextParams.DAMAGE_SOURCE, LootContextParams.ATTACKING_ENTITY, LootContextParams.DIRECT_ATTACKING_ENTITY, LootContextParams.ORIGIN, LootContextParams.BLOCK_STATE, LootContextParams.BLOCK_ENTITY, LootContextParams.TOOL, LootContextParams.EXPLOSION_RADIUS, LootContextParams.ADDITIONAL_COST_COMPONENT_ALLOWED, LootContextParams.CONTAINER, LootContextParams.INTERACTING_ENTITY, LootContextParams.TARGET_ENTITY, LootContextParams.ENCHANTMENT_ACTIVE, LootContextParams.ENCHANTMENT_LEVEL | — |
LootContextParamSets.GIFT | gift | LootContextParams.ORIGIN, LootContextParams.THIS_ENTITY | — |
LootContextParamSets.HIT_BLOCK | hit_block | LootContextParams.THIS_ENTITY, LootContextParams.ENCHANTMENT_LEVEL, LootContextParams.ORIGIN, LootContextParams.BLOCK_STATE | — |
LootContextParamSets.SELECTOR | selector | LootContextParams.ORIGIN, LootContextParams.THIS_ENTITY | — |
LootContextParamSets.SHEARING | shearing | LootContextParams.ORIGIN, LootContextParams.THIS_ENTITY, LootContextParams.TOOL | — |
LootContextParamSets.VAULT | vault | LootContextParams.ORIGIN | LootContextParams.THIS_ENTITY, LootContextParams.TOOL |
LootContextParamSets.VILLAGER_TRADE | villager_trade | LootContextParams.ORIGIN, LootContextParams.THIS_ENTITY, LootContextParams.ADDITIONAL_COST_COMPONENT_ALLOWED | — |
Entity spawn reasons
Generated from the 26.3 decompile by
tools/gen_reference.py. Do not edit by hand.
Every EntitySpawnReason constant, in declaration order, with what each one gates — the classes that compare against it and so behave differently for that reason — and how many classes pass it. A reason with an empty gates column changes nothing by itself: it is a label the spawn path carries for other code to read. EntitySpawnReason.isSpawner folds SPAWNER and TRIAL_SPAWNER together and EntitySpawnReason.ignoresLightRequirements is true of TRIAL_SPAWNER alone. See entity lifecycle.
19 reasons · 13 of them tested somewhere · 41 test sites
| # | reason | what it gates | classes that pass it |
|---|---|---|---|
| 0 | EntitySpawnReason.NATURAL | ZombieHorse.finalizeSpawn; Drowned.finalizeSpawn; Husk.finalizeSpawn; Raider.finalizeSpawn | 4 |
| 1 | EntitySpawnReason.CHUNK_GENERATION | nothing tests it | 2 |
| 2 | EntitySpawnReason.SPAWNER | Mob.checkMobSpawnRules (through EntitySpawnReason.isSpawner); SkeletonHorse.checkSkeletonHorseSpawnRules (through EntitySpawnReason.isSpawner); Endermite.checkEndermiteSpawnRules (through EntitySpawnReason.isSpawner); Guardian.checkGuardianSpawnRules (through EntitySpawnReason.isSpawner); Monster.checkSurfaceMonstersSpawnRules (through EntitySpawnReason.isSpawner); Silverfish.checkSilverfishSpawnRules (through EntitySpawnReason.isSpawner); Slime.checkSlimeSpawnRules (through EntitySpawnReason.isSpawner); Stray.checkStraySpawnRules (through EntitySpawnReason.isSpawner); Drowned.checkDrownedSpawnRules (through EntitySpawnReason.isSpawner) | 2 |
| 3 | EntitySpawnReason.STRUCTURE | PatrollingMonster.finalizeSpawn; Piglin.finalizeSpawn; Drowned.finalizeSpawn | 9 |
| 4 | EntitySpawnReason.BREEDING | Villager.finalizeSpawn | 41 |
| 5 | EntitySpawnReason.MOB_SUMMONED | nothing tests it | 2 |
| 6 | EntitySpawnReason.JOCKEY | nothing tests it | 5 |
| 7 | EntitySpawnReason.EVENT | TraderLlama.finalizeSpawn; PatrollingMonster.finalizeSpawn | 5 |
| 8 | EntitySpawnReason.CONVERSION | Husk.finalizeSpawn; Zombie.finalizeSpawn; Zombie.handleAttributes | 5 |
| 9 | EntitySpawnReason.REINFORCEMENT | Drowned.checkDrownedSpawnRules | 1 |
| 10 | EntitySpawnReason.TRIGGERED | Warden.finalizeSpawn | 12 |
| 11 | EntitySpawnReason.BUCKET | Axolotl.finalizeSpawn | 1 |
| 12 | EntitySpawnReason.SPAWN_ITEM_USE | nothing tests it | 4 |
| 13 | EntitySpawnReason.COMMAND | nothing tests it | 2 |
| 14 | EntitySpawnReason.DISPENSER | nothing tests it | 5 |
| 15 | EntitySpawnReason.PATROL | PatrollingMonster.finalizeSpawn | 1 |
| 16 | EntitySpawnReason.TRIAL_SPAWNER | Mob.checkMobSpawnRules (through EntitySpawnReason.isSpawner); Animal.checkAnimalSpawnRules (through EntitySpawnReason.ignoresLightRequirements); SkeletonHorse.checkSkeletonHorseSpawnRules (through EntitySpawnReason.isSpawner); SkeletonHorse.checkSkeletonHorseSpawnRules (through EntitySpawnReason.ignoresLightRequirements); Endermite.checkEndermiteSpawnRules (through EntitySpawnReason.isSpawner); Guardian.checkGuardianSpawnRules (through EntitySpawnReason.isSpawner); Monster.checkMonsterSpawnRules (through EntitySpawnReason.ignoresLightRequirements); Monster.checkSurfaceMonstersSpawnRules (through EntitySpawnReason.isSpawner); Silverfish.checkSilverfishSpawnRules (through EntitySpawnReason.isSpawner); Slime.checkSlimeSpawnRules (through EntitySpawnReason.isSpawner); Stray.checkStraySpawnRules (through EntitySpawnReason.isSpawner); Drowned.checkDrownedSpawnRules (through EntitySpawnReason.isSpawner); Drowned.checkDrownedSpawnRules (through EntitySpawnReason.ignoresLightRequirements) | 2 |
| 17 | EntitySpawnReason.LOAD | Zombie.handleAttributes | 6 |
| 18 | EntitySpawnReason.DIMENSION_TRAVEL | Zombie.handleAttributes | 1 |
Structure spawn overrides
Generated from the 26.3 decompile by
tools/gen_reference.py. Do not edit by hand.
Which structures replace a biome’s spawn list, for which mob category, and with what. A structure JSON’s spawn_overrides map is read into Structure.spawnOverrides; when NaturalSpawner builds a spawn list, ChunkGenerator.getMobsAt walks the structures that chunk references, in no fixed order, and the first that declares an override for the category and whose box contains the position answers; the biome’s list is then not consulted. An override with an empty spawn list is therefore a ban, not a no-op. The one exception is the creatures a chunk gets while it generates: NaturalSpawner.spawnMobsForChunkGeneration draws the creature category from the biome’s list and reads no override. box is piece (only inside a piece’s own bounding box) or full (anywhere in the structure’s box, which for a structure that adapts the terrain around it is grown by twelve blocks on every side). The mechanism is on entity lifecycle; the nether fortress has a second, hard-coded list in front of this one.
52 structures carry the field · 6 declare an override · 23 overrides in all
| structure | category | box | what spawns instead (weight, min–max) |
|---|---|---|---|
ancient_city | ambient | full | nothing — the category is suppressed inside the box |
ancient_city | axolotls | full | nothing — the category is suppressed inside the box |
ancient_city | creature | full | nothing — the category is suppressed inside the box |
ancient_city | misc | full | nothing — the category is suppressed inside the box |
ancient_city | monster | full | nothing — the category is suppressed inside the box |
ancient_city | underground_water_creature | full | nothing — the category is suppressed inside the box |
ancient_city | water_ambient | full | nothing — the category is suppressed inside the box |
ancient_city | water_creature | full | nothing — the category is suppressed inside the box |
fortress | monster | piece | blaze (10, 2–3), zombified_piglin (5, 4–4), wither_skeleton (8, 5–5), skeleton (2, 5–5), magma_cube (3, 4–4) |
monument | axolotls | full | nothing — the category is suppressed inside the box |
monument | monster | full | guardian (1, 2–4) |
monument | underground_water_creature | full | nothing — the category is suppressed inside the box |
pillager_outpost | monster | full | pillager (1, 1–1) |
swamp_hut | creature | piece | cat (1, 1–1) |
swamp_hut | monster | piece | witch (1, 1–1) |
trial_chambers | ambient | piece | nothing — the category is suppressed inside the box |
trial_chambers | axolotls | piece | nothing — the category is suppressed inside the box |
trial_chambers | creature | piece | nothing — the category is suppressed inside the box |
trial_chambers | misc | piece | nothing — the category is suppressed inside the box |
trial_chambers | monster | piece | nothing — the category is suppressed inside the box |
trial_chambers | underground_water_creature | piece | nothing — the category is suppressed inside the box |
trial_chambers | water_ambient | piece | nothing — the category is suppressed inside the box |
trial_chambers | water_creature | piece | nothing — the category is suppressed inside the box |
The other 46 structures carry the field empty — the field is required — and the biome’s list stands.
The weapon helpers on Item.Properties
Generated from the 26.3 decompile by
tools/gen_reference.py. Do not edit by hand.
The seven Item.Properties methods that turn a bare item into something you can hit with, what each one installs, and every item built by one. The components column names what a helper sets by name; through Item.Properties.durability, Item.Properties.repairable, Item.Properties.enchantable and Item.Properties.attributes every one also sets the durability components and a stack size of one, and the repair, enchantability and attribute-modifier components. Item.Properties.tool is the shared body: pickaxe, axe, hoe and shovel are it with a mining tag and a shield-disable time filled in; sword and spear go their own way. None of the six needs a class of its own: axe, hoe and shovel add DataComponents.BLOCK_TRANSFORMER, which the base Item.useOn runs on a right-click, so every item a helper builds is a plain Item. See items and stacks and data components.
| helper | delegates to | components it sets | attributes |
|---|---|---|---|
Item.Properties.tool | applyToolProperties | DataComponents.TOOL, DataComponents.WEAPON | Attributes.ATTACK_DAMAGE, Attributes.ATTACK_SPEED (through ToolMaterial) |
Item.Properties.pickaxe | tool | DataComponents.TOOL, DataComponents.WEAPON | Attributes.ATTACK_DAMAGE, Attributes.ATTACK_SPEED (through ToolMaterial) |
Item.Properties.axe | tool | DataComponents.BLOCK_TRANSFORMER, DataComponents.TOOL, DataComponents.WEAPON | Attributes.ATTACK_DAMAGE, Attributes.ATTACK_SPEED (through ToolMaterial) |
Item.Properties.hoe | tool | DataComponents.BLOCK_TRANSFORMER, DataComponents.TOOL, DataComponents.WEAPON | Attributes.ATTACK_DAMAGE, Attributes.ATTACK_SPEED (through ToolMaterial) |
Item.Properties.shovel | tool | DataComponents.BLOCK_TRANSFORMER, DataComponents.TOOL, DataComponents.WEAPON | Attributes.ATTACK_DAMAGE, Attributes.ATTACK_SPEED (through ToolMaterial) |
Item.Properties.sword | applySwordProperties | DataComponents.TOOL, DataComponents.WEAPON | Attributes.ATTACK_DAMAGE, Attributes.ATTACK_SPEED (through ToolMaterial) |
Item.Properties.spear | — | DataComponents.DAMAGE_TYPE, DataComponents.KINETIC_WEAPON, DataComponents.PIERCING_WEAPON, DataComponents.ATTACK_RANGE, DataComponents.MINIMUM_ATTACK_CHARGE, DataComponents.ATTACK_ANIMATION, DataComponents.USE_EFFECTS, DataComponents.WEAPON | Attributes.ATTACK_DAMAGE, Attributes.ATTACK_SPEED |
The 42 items built by one
damage and speed are the two baselines a tool or sword call passes; the material adds its own attack-damage bonus on top. A spear’s call passes an attack duration and a damage multiplier instead, so its row leaves both cells empty.
| item | helper | material | damage baseline | speed baseline |
|---|---|---|---|---|
Items.WOODEN_SWORD | Item.Properties.sword | ToolMaterial.WOOD | 3.0 | -2.4 |
Items.WOODEN_SHOVEL | Item.Properties.shovel | ToolMaterial.WOOD | 1.5 | -3.0 |
Items.WOODEN_PICKAXE | Item.Properties.pickaxe | ToolMaterial.WOOD | 1.0 | -2.8 |
Items.WOODEN_AXE | Item.Properties.axe | ToolMaterial.WOOD | 6.0 | -3.2 |
Items.WOODEN_HOE | Item.Properties.hoe | ToolMaterial.WOOD | 0.0 | -3.0 |
Items.COPPER_SWORD | Item.Properties.sword | ToolMaterial.COPPER | 3.0 | -2.4 |
Items.COPPER_SHOVEL | Item.Properties.shovel | ToolMaterial.COPPER | 1.5 | -3.0 |
Items.COPPER_PICKAXE | Item.Properties.pickaxe | ToolMaterial.COPPER | 1.0 | -2.8 |
Items.COPPER_AXE | Item.Properties.axe | ToolMaterial.COPPER | 7.0 | -3.2 |
Items.COPPER_HOE | Item.Properties.hoe | ToolMaterial.COPPER | -1.0 | -2.0 |
Items.STONE_SWORD | Item.Properties.sword | ToolMaterial.STONE | 3.0 | -2.4 |
Items.STONE_SHOVEL | Item.Properties.shovel | ToolMaterial.STONE | 1.5 | -3.0 |
Items.STONE_PICKAXE | Item.Properties.pickaxe | ToolMaterial.STONE | 1.0 | -2.8 |
Items.STONE_AXE | Item.Properties.axe | ToolMaterial.STONE | 7.0 | -3.2 |
Items.STONE_HOE | Item.Properties.hoe | ToolMaterial.STONE | -1.0 | -2.0 |
Items.GOLDEN_SWORD | Item.Properties.sword | ToolMaterial.GOLD | 3.0 | -2.4 |
Items.GOLDEN_SHOVEL | Item.Properties.shovel | ToolMaterial.GOLD | 1.5 | -3.0 |
Items.GOLDEN_PICKAXE | Item.Properties.pickaxe | ToolMaterial.GOLD | 1.0 | -2.8 |
Items.GOLDEN_AXE | Item.Properties.axe | ToolMaterial.GOLD | 6.0 | -3.0 |
Items.GOLDEN_HOE | Item.Properties.hoe | ToolMaterial.GOLD | 0.0 | -3.0 |
Items.IRON_SWORD | Item.Properties.sword | ToolMaterial.IRON | 3.0 | -2.4 |
Items.IRON_SHOVEL | Item.Properties.shovel | ToolMaterial.IRON | 1.5 | -3.0 |
Items.IRON_PICKAXE | Item.Properties.pickaxe | ToolMaterial.IRON | 1.0 | -2.8 |
Items.IRON_AXE | Item.Properties.axe | ToolMaterial.IRON | 6.0 | -3.1 |
Items.IRON_HOE | Item.Properties.hoe | ToolMaterial.IRON | -2.0 | -1.0 |
Items.DIAMOND_SWORD | Item.Properties.sword | ToolMaterial.DIAMOND | 3.0 | -2.4 |
Items.DIAMOND_SHOVEL | Item.Properties.shovel | ToolMaterial.DIAMOND | 1.5 | -3.0 |
Items.DIAMOND_PICKAXE | Item.Properties.pickaxe | ToolMaterial.DIAMOND | 1.0 | -2.8 |
Items.DIAMOND_AXE | Item.Properties.axe | ToolMaterial.DIAMOND | 5.0 | -3.0 |
Items.DIAMOND_HOE | Item.Properties.hoe | ToolMaterial.DIAMOND | -3.0 | 0.0 |
Items.NETHERITE_SWORD | Item.Properties.sword | ToolMaterial.NETHERITE | 3.0 | -2.4 |
Items.NETHERITE_SHOVEL | Item.Properties.shovel | ToolMaterial.NETHERITE | 1.5 | -3.0 |
Items.NETHERITE_PICKAXE | Item.Properties.pickaxe | ToolMaterial.NETHERITE | 1.0 | -2.8 |
Items.NETHERITE_AXE | Item.Properties.axe | ToolMaterial.NETHERITE | 5.0 | -3.0 |
Items.NETHERITE_HOE | Item.Properties.hoe | ToolMaterial.NETHERITE | -4.0 | 0.0 |
Items.WOODEN_SPEAR | Item.Properties.spear | ToolMaterial.WOOD | — | — |
Items.STONE_SPEAR | Item.Properties.spear | ToolMaterial.STONE | — | — |
Items.COPPER_SPEAR | Item.Properties.spear | ToolMaterial.COPPER | — | — |
Items.IRON_SPEAR | Item.Properties.spear | ToolMaterial.IRON | — | — |
Items.GOLDEN_SPEAR | Item.Properties.spear | ToolMaterial.GOLD | — | — |
Items.DIAMOND_SPEAR | Item.Properties.spear | ToolMaterial.DIAMOND | — | — |
Items.NETHERITE_SPEAR | Item.Properties.spear | ToolMaterial.NETHERITE | — | — |
Block update flags
Verified against Minecraft 26.3 · Reference · Hand-kept from
Block’s UPDATE_ constants: the ten bits ofLevel.setBlock’s flag word, what reads each, and the named combinations.
The flag word is Level.setBlock’s third argument — the last one on the
three-argument overload, and followed by an update limit on the four-argument
one. It is a bit set, tagged in signatures by Block.UpdateFlags, an
annotation that carries no values of its own.
Blocks and states
draws Level.setBlock as two flowcharts and a table whose gates name these
bits by number; this is the table behind the numbers, and every page of Parts IV
and V that passes a flag word means the same bits by them. Two of the
constants do not mean what they look like: Block.UPDATE_NONE is not zero
— it is 260, two bits set — and 512 names two different things, a bit and
a recursion budget that is not a bit at all.
| bit | constant | what reads it |
|---|---|---|
| 1 | Block.UPDATE_NEIGHBORS | the neighbour fan-out in the tail, and the gate on BlockBehaviour.BlockStateBase.affectNeighborsAfterRemoval in the chunk write |
| 2 | Block.UPDATE_CLIENTS | Level.sendBlockUpdated — on the server the broadcast, the path-type cache and, when the collision shape changed, the re-path check on every navigating mob; a re-mesh on the client, where it is LevelExtractor.blockChanged |
| 4 | Block.UPDATE_INVISIBLE | the client half of that same gate, and nothing else: Level.setBlock tests it only on the client, so a server write carrying it with bit 2 still broadcasts |
| 8 | Block.UPDATE_IMMEDIATE | one place in the game: LevelExtractor.blockChanged, which marks the re-mesh as player-caused |
| 16 | Block.UPDATE_KNOWN_SHAPE | three readers: Level.setBlock, where it suppresses all three shape passes; BlockInput.place, where it decides whether the state is fixed up against its neighbours before the write; and WorldGenRegion.setBlock, where it suppresses the post-processing mark |
| 32 | Block.UPDATE_SUPPRESS_DROPS | Block.updateOrDestroy, whose destroy branch drops resources unless it is set. A one-level flag: it is masked out of the word as it propagates, both to the neighbours and into the recursive write — and no caller hands Block.updateOrDestroy a word with it set, so in this release it changes nothing |
| 64 | Block.UPDATE_MOVE_BY_PISTON | passed on as movedByPiston, and lets BlockBehaviour.BlockStateBase.affectNeighborsAfterRemoval run without bit 1 |
| 128 | Block.UPDATE_SKIP_SHAPE_UPDATE_ON_WIRE | NeighborUpdater.executeShapeUpdate, which then skips any shape update whose target is dust, whatever the source. Only the experimental wire evaluator sets it (signal and dust) |
| 256 | Block.UPDATE_SKIP_BLOCK_ENTITY_SIDEEFFECTS | suppresses BlockEntity.preRemoveSideEffects |
| 512 | Block.UPDATE_SKIP_ON_PLACE | suppresses BlockBehaviour.BlockStateBase.onPlace |
Four named combinations stand beside the bits, decomposed here rather than in the reader’s head, and only the first two are ever named by a page in this book.
| constant | value | the bits | where the book meets it |
|---|---|---|---|
Block.UPDATE_ALL | 3 | 1 + 2 | the common write — every page that says flags 3 means this |
Block.UPDATE_ALL_IMMEDIATE | 11 | 1 + 2 + 8 | placement, your client’s predicted break, and an emptied bucket |
Block.UPDATE_NONE | 260 | 4 + 256 | nowhere in this book by name — and none is a misnomer: a write with it tells no client, sends no neighbour update and skips the block entity’s side effects, but still runs BlockBehaviour.BlockStateBase.onPlace and the three shape passes, which reach the neighbours all the same |
Block.UPDATE_SKIP_ALL_SIDEEFFECTS | 816 | 16 + 32 + 256 + 512 | nowhere in this book; it is the strict mode of /setblock, /fill, /clone and /place and of a structure block, and what /clone’s temporary barriers, a game test’s template and a tree clearing its saplings pass — each with bit 2 added, so the clients are still told; the one write that carries it without bit 2 is the barrier StructureTemplate.placeInWorld sets at 820 where a block with data is about to land, and overwrites at once |
512 is two constants and means two unrelated things. As a bit
it is Block.UPDATE_SKIP_ON_PLACE. As Block.UPDATE_LIMIT it is not a bit
at all: it is the default value of Level.setBlock’s fourth argument, the
recursion budget for the shape cascade, counting recursion depth rather
than requests — which is what keeps it distinct from
CollectingNeighborUpdater.maxChainedNeighborUpdates (block
interaction),
which counts requests.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Damage outside LivingEntity
Verified against Minecraft 26.3 · Reference · Hand-kept from
net/minecraft/world/entity/**.
Entity.hurtServer is abstract, so every branch has to answer for itself.
The twenty-two classes are every non-LivingEntity class that declares it, and
none of them runs LivingEntity’s rules itself — no armour, no
hurt cooldown, no CombatTracker, no death sequence. The lecture that
frames them is damage and
death;
this is the per-class table.
The last column is the client half. Entity.hurtClient has a default — it
returns false — and thirteen of the twenty-two inherit it unchanged; eight
declare their own and MinecartTNT inherits VehicleEntity’s. It is never handed the damage amount — Entity.hurtOrSimulate drops it on the client — and it answers whether the client should play a hit’s effects itself, for its own swing and for a projectile hit it simulates.
A swing reaches about half of these. The client picks only an entity whose Entity.isPickable is true, which among the twenty-two is Interaction, PrimedTnt, FallingBlockEntity, the three projectiles a player can deflect, the attached blocks, EndCrystal, ShulkerBullet, EnderDragonPart and the vehicles. Two gates follow in Player.attack, which the client runs as it sends the attack and the server runs again — Player.cannotAttack and Player.deflectProjectile (the sword swing owns both). The first refuses a class whose Entity.isAttackable is false (EyeOfEnder, FallingBlockEntity, ExperienceOrb, ItemEntity, and in the projectile row a firework, every arrow and a thrown trident — though the server disconnects a player whose attack names an item, an orb or an arrow before Player.attack runs) or whose Entity.skipAttackInteraction answers true (Interaction while its response flag is off; BlockAttachedEntity answers true outright for a player who may not interact at its position, and otherwise by running its own hurt with no damage). The second deflects those three projectiles, except a player’s WindCharge in its first five ticks, which refuses. Three rows below name their gate in the second column; PrimedTnt is the one nothing row a swing always reaches, and Interaction with its response flag on and a wind charge in its first five ticks reach theirs too; each Entity.hurtServer then does nothing. Entity.hurtOrSimulate is what Player.attack calls after the gates,
and it picks Entity.hurtServer or Entity.hurtClient off the level.
| class | what it checks first | what it does | returns | Entity.hurtClient |
|---|---|---|---|---|
AreaEffectCloud | — | nothing | false | false |
Display | — | nothing | false | false |
Interaction | not reached while its response flag is off — Entity.skipAttackInteraction answers the flag’s negation, after recording the attacker | nothing | false | false |
LightningBolt | — | nothing | false | false |
Marker | — | nothing | false | false |
OminousItemSpawner | — | nothing | false | false |
PrimedTnt | — | nothing: a lit TNT block cannot be shot out of the air | false | false |
EvokerFangs | — | nothing | false | false |
EyeOfEnder | never reached — Entity.isAttackable returns false | nothing | false | false |
AbstractHurtingProjectile | not reached but by a player’s wind charge in its first five ticks — a large fireball or a wind charge is otherwise deflected, and the rest are unhittable by Entity.isPickable | nothing | false | false |
Projectile | Entity.isInvulnerableToBase | Entity.markHurt only, so the client is sent the entity’s motion again and nothing changes | false | false |
FallingBlockEntity | Entity.isInvulnerableToBase | Entity.markHurt only | false | false |
ExperienceOrb | Entity.isInvulnerableToBase | subtracts the damage from an int of health, Entity.discard at zero | true | not invulnerable |
ItemEntity | Entity.isInvulnerableToBase, then a Mob source under GameRules.MOB_GRIEFING, then ItemStack.canBeHurtBy | same int of health, plus GameEvent.ENTITY_DAMAGE, and ItemStack.onDestroyed before the discard | true | not invulnerable, and ItemStack.canBeHurtBy agrees |
BlockAttachedEntity | Entity.isInvulnerableToBase, then a Mob source under GameRules.MOB_GRIEFING | BlockAttachedEntity.kill, Entity.markHurt, and BlockAttachedEntity.dropItem — one hit, whatever the amount; a leash knot’s drop is only a sound | true | not invulnerable |
Cushion | a Player source that may not build, or may not interact at the cushion’s position, is refused | otherwise BlockAttachedEntity’s one hit | true, or false when refused or under BlockAttachedEntity’s own gates | false for a player who may not build, then BlockAttachedEntity’s |
ItemFrame | ItemFrame.fixed gates everything: a fixed frame is hurt only by DamageTypeTags.BYPASSES_INVULNERABILITY or a creative player | a non-explosion hit on a frame holding something pops the item and stops there; otherwise it falls through to BlockAttachedEntity and the frame breaks | true | the fixed gate first, then not invulnerable |
EndCrystal | Entity.isInvulnerableToBase, then is the source an EnderDragon | removes itself with Entity.RemovalReason.KILLED and explodes with power 6 — unless the source was already an explosion — then EndCrystal.onDestroyedBy | true | not invulnerable, and not the dragon |
ShulkerBullet | — | plays SoundEvents.SHULKER_BULLET_HURT, spawns fifteen ParticleTypes.CRIT, destroys itself | true | true, unconditionally |
EnderDragonPart | Entity.isInvulnerableToBase | forwards the whole call to EnderDragon.hurt with itself as the part that was hit | the parent’s answer | false |
VehicleEntity | already removed, then Entity.isInvulnerableToBase | VehicleEntity.setDamage adds damage × 10, after flipping the hurt direction and setting ten ticks of hurt time; past 40 it is destroyed. A creative player gets all of that too, and then the vehicle is discarded at once whatever the accumulator reads — Entity.discard, which drops nothing | true | true, unconditionally |
MinecartTNT | a burning AbstractArrow as the direct entity explodes it, scaled by the arrow’s speed | on that path, nothing else when GameRules.TNT_EXPLODES is on or the cart is primed: the explosion calls Entity.discard, so VehicleEntity.hurtServer returns at its already-removed test. Any other source runs VehicleEntity.hurtServer, except that a fire or explosion source or a burning projectile goes straight to MinecartTNT.destroy, which lights the fuse, even for a creative player | true | inherited: true |
Six patterns account for all of it: nothing happens (ten classes), a motion resend and nothing else (two), an int of health with no armour and no
hurt cooldown (two), one hit destroys (five, though a non-explosion hit on an unfixed frame holding something takes only the item), an accumulator (two), and
forward it to something else (one, the dragon part). Only four classes
read the damage amount at all: the pair with an int of health and the
accumulator pair. EnderDragonPart passes the number on without looking at it,
and everything else is a yes-or-no.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
What the HUD draws, and when
Verified against Minecraft 26.3 · Reference · Hand-kept from
Hud.extractRenderStateandGui.extractRenderState.
The HUD is one ordered sweep down two methods — the rows numbered 1 to 27 in Hud.extractRenderState and then four more in Gui.extractRenderState,
which is the split the second table below is about — and almost every element
in it is behind a condition. Four places in it record one element or another: the spyglass or the equipment overlay (rows 2 and 3), the portal or the nausea overlay (5 and 6), the hotbar or a spectator’s (8), and the item name or a spectator’s action (17 and 17b); food and the mount’s health (11 and 13) exclude each other too, though each is decided in its own method. The contextual bar is the exception, recorded
unconditionally and made to draw nothing by an empty state object instead.
The lecture that frames this is the HUD; this is
the table it is built on, in record order — which is also the order
things appear in front of each other, since the GUI render
tree infers layering from call
order and bounding boxes.
Three gates sit above everything in the first table and none of them belongs to Hud —
resources loaded, the frame advancing game time, a level existing; the
HUD is where they are
explained. Below them Hud.extractRenderState short-circuits entirely while a
LevelLoadingScreen is up, publishing GuiRenderState.isHudHidden before it
does.
Hidden by F1 means Hud.isHidden, which Options.keyToggleGui — F1 —
flips. It is the fourth column in the table below and the last in the
second one.
| # | element | recorded by | hidden by F1? | its own condition |
|---|---|---|---|---|
| 1 | vignette | Hud.extractVignette | yes | Options.vignette is on |
| 2 | spyglass overlay | Hud.extractSpyglassOverlay | yes | first-person camera and the player is scoping |
| 3 | equipment camera overlay | Hud.extractTextureOverlay | yes | first-person, not scoping, and some equipped item’s Equippable declares a camera overlay for the slot it is in |
| 4 | powder-snow outline | Hud.extractTextureOverlay | yes | Entity.getTicksFrozen above zero |
| 5 | portal overlay | Hud.extractPortalOverlay | yes | the interpolated portal intensity is above zero |
| 6 | nausea overlay | Hud.extractConfusionOverlay | yes | else: no portal effect, a nausea blend above zero, and Options.screenEffectScale below one |
| 7 | crosshair | Hud.extractCrosshair | yes | first-person; either not a spectator or a hit result a spectator may see; and the F3 three-dimensional-crosshair entry is off |
| — | new stratum | GuiGraphicsExtractor.nextStratum | — | a hard layering barrier — everything below is above everything above. Hud calls it ten times in all; this is the one that separates the overlays from the rest, and there is another inside the crosshair |
| 8 | hotbar | Hud.extractItemHotbar | yes | a camera player exists — replaced in spectator mode by SpectatorGui.extractHotbar, which records only while a spectator menu is open |
| 9 | armour | Hud.extractArmor | yes | inside the health block, and LivingEntity.getArmorValue above zero |
| 10 | hearts | Hud.extractHearts | yes | inside the health block |
| 11 | food | Hud.extractFood | yes | inside the health block, and the vehicle contributes no hearts |
| 12 | air bubbles | Hud.extractAirBubbles | yes | inside the health block, and the player’s eyes are in water or the air supply is below its maximum |
| 13 | mount health | Hud.extractVehicleHealth | yes | a ridden LivingEntity with a non-zero heart count — outside the health block, so creative shows it |
| 14 | contextual bar, background | ContextualBar.extractBackground | yes | always recorded, but which of four states it is in is re-decided every frame by Hud.nextContextualInfoState |
| 15 | experience level | ContextualBar.extractExperienceLevel | yes | the game mode has experience and the level is above zero — recorded between the bar’s two passes, so it survives whichever bar wins |
| 16 | contextual bar, foreground | ContextualBar.extractRenderState | yes | always recorded; empty in ContextualBar.EMPTY, ExperienceBar and JumpableVehicleBar, so LocatorBar is the only one of the four states that draws anything here |
| 17 | selected item name | Hud.extractSelectedItemName | yes | not a spectator, Hud.toolHighlightTimer above zero, and the stack is not empty |
| 17b | spectator action name | SpectatorGui.extractAction | yes | else: the game mode is spectator and the player is one, and a spectator menu is open — the same slot as row 17, and the second of the two places SpectatorGui replaces a Hud element |
| 18 | status effects | Hud.extractEffects | yes | the player has effects, no screen is showing them itself, and the instance sets MobEffectInstance.showIcon |
| 19 | boss bars | BossHealthOverlay | yes | the overlay has events |
| 20 | sleep fade | Hud.extractSleepOverlay | no | Player.getSleepTimer above zero — the one element between the two hidden-gated blocks |
| 21 | demo text | Hud.extractDemoOverlay | yes | Minecraft.isDemo |
| 22 | scoreboard sidebar | Hud.displayScoreboardSidebar | yes | a display objective for the team’s colour slot, else for DisplaySlot.SIDEBAR |
| 23 | action bar | Hud.extractOverlayMessage | yes | Hud.overlayMessageString is set and its timer has not run out |
| 24 | title and subtitle | Hud.extractTitle | yes | Hud.title is set and Hud.titleTime is above zero |
| 25 | chat | ChatComponent.extractRenderState | yes | a player exists and the chat screen is not focused |
| 26 | tab list | PlayerTabOverlay.extractRenderState | yes | Options.keyPlayerList is down, and either this is not a local server, or more than one player is listed, or a DisplaySlot.LIST objective exists |
| 27 | subtitles | SubtitleOverlay | yes, with one exception | Options.showSubtitles is on, and a subtitle-carrying sound has played within its own range — a distance test, not a volume one, so a category muted to silence still puts its subtitle on screen. Deferred when there is no screen or the screen declares itself in-game UI, and recorded even while hidden if such a screen is up — which is the exception |
The four elements a reader expects at the end of that list are not on
Hud.extractRenderState’s list at all: Gui.extractRenderState records them
instead, in the order below, with the overlay or the screen going in between.
Three of the four are still Hud methods called from the outer one —
Hud.extractSavingIndicator, Hud.extractDebugOverlay and
Hud.extractDeferredSubtitles — and only the toasts, which are
ToastManager.extractRenderState’s, are outside Hud entirely.
The row with no number is the overlay-or-screen slot rather than an element:
| # | element | its own condition | hidden by F1? |
|---|---|---|---|
| — | the overlay, or else the screen | Gui.overlay if there is one — the loading overlay is the one you will have seen; otherwise, and only once resources are loaded, Gui.screen through Screen.extractRenderStateWithTooltipAndSubtitles. Gui never records both, though LoadingOverlay itself records the screen beneath its fill while it fades in and out; this is the whole of what a screen contributes to the record | — |
| 28 | saving indicator | Options.showAutosaveIndicator is on, the frame is drawing a level, and a save is still animating | no |
| 29 | toasts | resources are loaded | checks the flag itself |
| 30 | debug overlay | resources are loaded, the current screen is not DebugOptionsScreen, and some debug entry is on — one set to show always, or, while F3 is open, one set to show in the overlay | checks the flag itself |
| 31 | deferred subtitles | row 27 deferred them — but only when neither a screen nor the loading overlay is up: a screen draws them itself from Screen.extractBackground, below its own widgets, LoadingOverlay draws them itself, and this call then finds nothing left | — |
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Submit phases and feature renderers
Verified against Minecraft 26.3 · Reference · Hand-kept from
SubmitNodeCollectionand theFeatureRenderDispatcherconstructor.
Everything an entity, block entity, particle or debug renderer draws in a
level arrives as a submit node — the sky is drawn by its own renderer in its
own frame-graph pass; the clouds, the weather and the world border by their
own renderers inside the main pass; and terrain by its chunk sections.
SubmitNodeCollection sorts those nodes into fifteen named phases as they
come in, and twelve feature renderers turn them into vertices. The lecture
that frames both is entity rendering,
which names four of the phases and one of the renderers. Two more renderers
are named elsewhere — TextFeatureRenderer on
text and fonts, QuadParticleFeatureRenderer
on particles — and everything else here is
only here.
The fifteen phases
One SubmitNodeCollection holds fourteen of them, and a SubmitNodeStorage
holds one collection per order bucket — the global draw order a submit
chooses with
SubmitNodeCollector.order —
and the fifteenth, seeThrough, which every one of its collections shares.
So “every order bucket” below means every one of those collections in turn.
Those fifteen are the set with improved transparency off, which is how every
storage but LevelRenderer’s always runs; the level’s follows
GameRenderer.useImprovedTransparency each frame, through
SubmitNodeStorage.setUseImprovedTransparency. With it on, rows 2 to 10 and
row 12 are ten names for one simple phase, SubmitNodeCollection.oitTranslucent,
which FeatureRenderDispatcher.PreparedFrame.executeOit drains three times,
once per OitStage. Row 11 is drained on its own, by FeatureRenderDispatcher.PreparedFrame.executeWaterMask — twice a frame when there is a water mask.
Rows 1 to 14 are in the order of SubmitNodeCollection.allPhases, and row 15
comes last because SubmitNodeStorage.drainPhases hands it over after every
bucket’s. That is not the order they are drawn in, and neither is the last
column: FeatureRenderDispatcher.PreparedFrame.executeTranslucent
alone makes three separate sweeps over every order bucket, so the phases it
drains are numbered by sweep — but within a sweep the order is the one the
sweep’s own statements run in, not the order of the rows. Sweep 1 drains
shadows, then translucent models, then name tags, then texts, then
translucent custom geometry — so in each bucket translucent models, row 7, are
drawn before the name tags and texts of rows 3 and 4.
Three of the fifteen are a TranslucentFeatureRenderPhase — rows 6, 7 and 15,
translucentBlocksAndItems, translucentModels and seeThrough — and the
other twelve a SimpleFeatureRenderPhase, which is not what two of the four translucent names suggest. What lets those three sort at all is on the node rather than the
phase: TranslucentSubmit is the interface that carries it, through TranslucentSubmit.distanceToCameraSq, and five of the twelve renderers’
nested Submit records implement it — BlockModelFeatureRenderer,
ItemFeatureRenderer, ModelFeatureRenderer, MovingBlockFeatureRenderer and
TextFeatureRenderer. A phase can only order what its nodes can answer. What the two kinds do differently to a node’s chance of sharing a draw belongs to entity rendering.
| # | phase | what lands in it | drained by |
|---|---|---|---|
| 1 | SubmitNodeCollection.solid | the opaque default: models whose RenderType is not an outline and does not blend or forces the solid phase (RenderType.forceSolidModelPhase), block models whose render type neither blends nor outlines, moving blocks whose model does not declare the translucent material flag, an item’s quads whose render type does not blend, custom geometry that neither blends nor outlines, the opaque half of every quad-particle group, every flame and every leash — plus the opaque cases rows 3, 4, 5, 9, 10 and 12 send here | .executeSolid |
| 2 | SubmitNodeCollection.shadows | one node per SubmitNodeCollection.submitShadow, carrying the radius and the EntityRenderState.ShadowPiece list sampled at extract | .executeTranslucent, sweep 1 |
| 3 | SubmitNodeCollection.nameTags | every name tag’s node in Font.DisplayMode.NORMAL, see-through or not — a see-through one’s carries an emission bump on its light, opaque white and no background, and while the level renders goes to solid instead | .executeTranslucent, sweep 1 |
| 4 | SubmitNodeCollection.texts | world-space text that is not a name tag, from SubmitNodeCollection.submitText, and a text display’s background, from SubmitNodeCollection.submitTextBackground, when not see-through — though while the level renders, text in opaque colour with no background goes to solid | .executeTranslucent, sweep 1 |
| 5 | SubmitNodeCollection.shapeOutlines | VoxelShape edge outlines submitted without the after-terrain flag, unless their colour is fully opaque — those go to solid, flag or not | .executeTranslucent, sweep 2 |
| 6 | SubmitNodeCollection.translucentBlocksAndItems | an item’s quads whose render type blends — an item with both kinds is two nodes, here and in solid — block models whose render type blends, and moving blocks whose model declares the translucent material flag | .executeTranslucent, sweep 3 |
| 7 | SubmitNodeCollection.translucentModels | models whose RenderType blends, does not force the solid phase and is not the water mask | .executeTranslucent, sweep 1 |
| 8 | SubmitNodeCollection.translucentCustomGeometry | custom geometry whose RenderType blends — a simple phase in spite of the name, so it is not distance-sorted | .executeTranslucent, sweep 1 |
| 9 | SubmitNodeCollection.translucentGizmos | the translucent debug primitive group submitted without the on-top flag — its opaque twin goes to solid | .executeTranslucent, sweep 2 |
| 10 | SubmitNodeCollection.breakingOverlay | the crumbling decal over geometry that blends: a model’s ModelFeatureRenderer.CrumblingOverlay, from SubmitNodeCollection.submitCrumblingOverlay on a render type that admits one, and a SubmitNodeCollection.submitBreakingBlockModel for a block whose model declares the translucent material flag — over anything opaque the decal goes to solid | .executeTranslucent, sweep 3 |
| 11 | SubmitNodeCollection.waterMask | models submitted with the water-mask render type | .executeTranslucent, sweep 3 |
| 12 | SubmitNodeCollection.afterTerrain | outlines flagged after-terrain, unless fully opaque, plus the translucent half of every quad-particle group | .executeTranslucentAfterTerrain |
| 13 | SubmitNodeCollection.alwaysOnTopGizmos | gizmo groups flagged on top, opaque or not | .executeAlwaysOnTop |
| 14 | SubmitNodeCollection.outline | a second copy of a model, block model, moving block or item whose outline colour was non-zero — a model or block model only where its render type has an outline variant, and a moving block or item with its ordinary type, re-typed quad by quad inside the feature renderer to the outline variant every item and moving-block render type has — plus custom geometry, and a model or block model submitted with an outline render type (an invisible entity that glows), which are not a second copy at all: an outline render type routes the only copy here | .executeOutline, which FeatureRenderDispatcher.renderAllFeatures never calls — LevelRenderer does, into its own target |
| 15 | SubmitNodeCollection.seeThrough | the storage’s one phase, SubmitNodeStorage.seeThrough, not one per bucket: the second node a see-through name tag emits, in Font.DisplayMode.SEE_THROUGH with the background restored, and any text or text background submitted in that mode — sorted by distance across every bucket at once | FeatureRenderDispatcher.PreparedFrame.executeSeeThrough, once for the whole storage |
Two things in the table are worth reading twice. A quad-particle group reaches the collector in one call and becomes two nodes, one in solid and one in afterTerrain, each wrapping the same render state with a flag that picks which of its layers it draws. And outline is not simply solid submitted twice: the glow is a second submission for a model, a block model, a moving block or an item, but flames, leashes and quad particles never reach it at all, a blending model pairs its outline copy with translucentModels rather than solid unless its render type forces the solid phase, and custom geometry, like a model submitted with an outline render type, goes to one phase or the other and never both.
The twelve feature renderers
In the order FeatureRenderDispatcher’s constructor registers them. All but
one extend RenderTypeFeatureRenderer, which owns the shared
StagedVertexBuffer draw and the merging of geometry that shares a render type.
| feature renderer | what it writes |
|---|---|
ShadowFeatureRenderer | four vertices per EntityRenderState.ShadowPiece onto one shared shadow render type, with the piece’s alpha as the colour and UVs derived from its bounds and the shadow radius |
FlameFeatureRenderer | a stack of fire quads scaled to the entity’s bounding box, alternating two block-atlas sprites and flipping their U every other pair, at full block light |
ModelFeatureRenderer | models — an entity’s, a block entity’s, a special item’s such as a shield or trident, and the elder guardian particle’s: Model.setupAnim and then Model.renderToBuffer over the ModelPart tree, into a buffer optionally wrapped for a sheeted decal or a UvMapping — an atlas sprite, or an armour trim’s paletted texture |
TextFeatureRenderer | arbitrary world-space text and every name tag: glyph quads and background prepared through Font at the submitted pose and display mode, with an eight-direction outline pass and a polygon-offset second pass when an outline colour is set, and a text display’s background submitted on its own |
LeashFeatureRenderer | a strip between the two ends of an EntityRenderState.LeashState, walked twice, as two ribbons that cross — twenty-five stations out and twenty-five back, a pair of vertices at each, a hundred in all — with its light interpolated between the endpoints and its colour alternating per station |
ItemFeatureRenderer | item quads, in one pass over the submits — a stack with a foil draws each quad on its material’s glint render type, which samples the enchantment glint beside the item texture, so geometry and foil go out together |
CustomFeatureRenderer | nothing of its own: it hands the caller’s SubmitNodeCollector.CustomGeometryRenderer a vertex consumer for the requested render type |
BlockModelFeatureRenderer | the quads of a BlockStateModelPart list, in Direction order, at one fixed light and overlay coordinate for the whole submit |
MovingBlockFeatureRenderer | a whole block state re-tesselated through ModelBlockRenderer — pistons, falling blocks and the transient blocks a server sends — honouring the ambient-occlusion and cutout-leaves options |
QuadParticleFeatureRenderer | the only one outside RenderTypeFeatureRenderer: it appends particle draws to the StagedVertexBuffer per SingleQuadParticle.Layer and issues them into the RenderPass it is handed, on each layer’s own pipeline |
ShapeOutlineFeatureRenderer | two line vertices per edge of a VoxelShape, each carrying a per-vertex line width |
GizmoFeatureRenderer | the debug vocabulary: quads, triangle fans, lines, texts and points out of a DrawableGizmoPrimitives.Group, camera-relative |
Those twelve are the answer to what can be drawn from a submit node —
which, by the exclusions the opening makes, is everything in a level except
the sky, the clouds, the weather, the world border and terrain. Anything a
renderer wants that none of the other eleven draws goes through
CustomFeatureRenderer.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Density-function nodes
Verified against Minecraft 26.3 · Reference · the forty-four node types a worldgen/density_function file may name: what each one takes, what the compiler turns it into, what range it reports, and which ids the shipped data actually writes.
Density functions is the lecture: one graph, rewritten twice and compiled once per dimension, and one kind of cache. This is the catalogue behind it — the four tables you would pause the video to read.
DensityFunctions.bootstrap registers every entry below into
BuiltInRegistries.DENSITY_FUNCTION_TYPE, in this order, under the
minecraft namespace. That registry is built-in and
frozen at start-up,
which is why adding a new kind of node takes code while adding a new graph
takes a JSON file.
Forty-four node types are registered (DensityFunctions.bootstrap): one by name,
then three context values, seven more by name, eleven unary transforms, four
roundings, six arithmetic, and twelve last.
The table
children counts the density-function slots; each one accepts an id string,
an inline object or a bare number, because every child slot is typed
DensityFunction.CODEC.
| id | class | children | other fields | what it computes |
|---|---|---|---|---|
| constant | ConstantFunction | 0 | value | a fixed value |
| blend_alpha | SimpleDensityFunction | 0 | — | the chunk’s blend alpha from the sampling context, else 1.0 |
| blend_offset | SimpleDensityFunction | 0 | — | the chunk’s blend offset from the context, else 0.0 |
| beardifier | SimpleDensityFunction | 0 | — | the chunk’s structure-terrain adjustment from the context, else 0.0 |
| noise | NoiseFunction | 3, optional | noise, xz_scale, y_scale | samples the noise a NormalNoise defines at position × scale, plus three offsets |
| end_outer_islands | EndIslandFunction | 0 | — | the End’s simplex field of outer islands, as a density |
| distance_to_point | DistanceToPointFunction | 0 | point, metric | the distance from a fixed point, by one of four metrics |
| gradient | GradientFunction | 0 | axis, tiling, from_coordinate, to_coordinate, from_value, to_value | one coordinate mapped onto a value range, clamped, repeated or mirrored past the ends |
| shift_a | ShiftNoiseFunction.ShiftA | 0 | noise | domain warp read at x, 0, z |
| shift_b | ShiftNoiseFunction.ShiftB | 0 | noise | domain warp read at z, x, 0 |
| shift | ShiftNoiseFunction.Shift | 0 | noise | domain warp read at x, y, z |
| abs | UnaryFunction | 1 | — | absolute value |
| square | UnaryFunction | 1 | — | the child squared |
| cube | UnaryFunction | 1 | — | the child cubed |
| sqrt | UnaryFunction | 1 | — | the square root |
| half_negative | UnaryFunction | 1 | — | identity above zero, halved below |
| quarter_negative | UnaryFunction | 1 | — | identity above zero, quartered below |
| reciprocal | UnaryFunction | 1 | — | the reciprocal |
| negate | UnaryFunction | 1 | — | the child negated |
| squeeze | UnaryFunction | 1 | — | clamp to ±1, then a soft odd cubic |
| log | UnaryFunction | 1 | — | the natural logarithm |
| sign | UnaryFunction | 1 | — | the child’s sign: −1, 0 or 1 |
| floor | RoundFunction | 2 | — | rounded down to a multiple of multiple, 1 unless given |
| round | RoundFunction | 2 | — | rounded to the nearest multiple |
| ceil | RoundFunction | 2 | — | rounded up to a multiple |
| truncate | RoundFunction | 2 | — | rounded toward zero to a multiple |
| add | BinaryFunction | 2 | — | the sum |
| sub | BinaryFunction | 2 | — | the difference |
| mul | BinaryFunction | 2 | — | the product — with neither child a constant, a single sample short-circuits when the first is exactly zero |
| div | BinaryFunction | 2 | — | the quotient, with the same short-circuit |
| min | BinaryFunction | 2 | — | the minimum — with neither child a constant, a single sample skips the second when the first is already at or below the second’s lower bound |
| max | BinaryFunction | 2 | — | the maximum, with the symmetric skip |
| pow | PowFunction | 2 | — | base raised to exponent |
| spline | SplineFunction | inside the spline | spline | a CubicSpline whose coordinates are themselves density functions |
| lerp | LerpFunction | 3 | — | from first to second, by alpha |
| clamp | ClampFunction | 1 | min, max | the child, clamped |
| range_choice | RangeChoiceFunction | 3 | min_inclusive, max_exclusive | one of two branches, by whether the input is in range |
| interval_select | IntervalSelectFunction | 1 + a list | thresholds | the branch whose ascending threshold the input first falls below |
| cache | CacheFunction | 1 | — | nothing of its own — a request the compiler replaces with a cache of its child; left in place it refuses to compile |
| blend_density | BlendDensityFunction | 1 | — | the child, blended toward old-terrain density where the context holds a non-empty Blender |
| interpolated | InterpolatedFunction | 1 | cell_size_xz, cell_size_y | the child read at cell corners and interpolated between them |
| slice | SliceFunction | 1 | axis, coordinate | the child read with one coordinate pinned |
| find_top_surface | FindTopSurfaceFunction | 2 | lower_bound, cell_height | steps down in strides until the density goes positive, and returns that Y |
| old_blended_noise | BlendedNoise | 0 | xz_scale, y_scale, xz_factor, y_factor, smear_scale_multiplier | the pre-1.18 terrain noise, decoded unseeded |
Where one class serves several ids. The three context values are the
constants of one enum, SimpleDensityFunction; the eleven unary transforms
are all UnaryFunction, a record of a UnaryFunction.Type and its input;
the four roundings share RoundFunction, and the six arithmetic ids share
BinaryFunction. In each case an enum constant carries its own codec, and
the node’s codec() returns it — which is how a re-serialised graph comes
back with the right id. Nothing is folded while parsing:
the parse step
builds each record as written, a constant operand is specialised only when
the graph is compiled (into a sampler such as BinaryFunction.ConstAddSampler),
and add in the JSON comes back as add.
One member of DensityFunctions is not in this table because it is not
registered: DensityFunctions.HolderHolder, the in-memory stand-in for an id
reference, which has no codec at all.
What the caches become
DensityFunctionCompiler.getSampler is the
compile step:
each dimension’s RandomState compiles a graph the first time it is asked
for, and no chunk rewrites anything. A cache node is a request. Before
compiling, the compiler’s rewrite inlines every DensityFunctions.HolderHolder
and installs a DensityFunctionCompiler.PreparedCache in place of each
cache node — one per distinct input, so two cache nodes around equal
graphs share one cache. A second rewrite wraps each subgraph that ignores an
axis its parent reads, constants and gradients aside, in a slice pinned at
zero on that axis. A prepared cache refuses to encode, so only the parsed
graph, which the rewrites copy rather than change, re-serialises.
| node | compiled to | keyed on |
|---|---|---|
| cache | a CachingDensitySampler carrying the prepared cache’s number; its storage is a cell of the SamplerContext it runs in | the volume for a volume read — one buffer, refilled when a different DensityVolume asks — and the position for a single sample: the last block sampled, or its entry in the cached volume |
| interpolated | a sampler that reads the child at the cell corners, a volume stepped by cell_size_xz and cell_size_y, and interpolates every block between | nothing — no value outlives the read |
| blend_density | a sampler that asks the context for this chunk’s Blender and blends only when there is a non-empty one | not cached |
A context has cache cells only when it was built with
SamplerContext.Builder.enableCaches: NoiseChunk builds one for each chunk and for each height query, and the biome step, the structure starts and checks, a caching biome resolver, the spawn search and the F3 readout build their own,
and SamplerContext.EMPTY_UNCACHED passes every cached read straight through.
Three of the registered nodes above are the constants of one enum,
SimpleDensityFunction, and the compiler replaces none of them: each compiles
to a ContextBoundSampler that looks its key up in the sampling context on
every read, which is how one compiled graph serves every chunk.
| singleton | reads | with nothing in the context |
|---|---|---|
SimpleDensityFunction.BLEND_ALPHA | Blender.ALPHA_KEY, a sampler over the alphas NoiseChunk’s constructor computes at quart resolution when the blender is not empty | the constant 1.0 |
SimpleDensityFunction.BLEND_OFFSET | Blender.OFFSET_KEY, likewise | the constant 0.0 |
SimpleDensityFunction.BEARDIFIER | Beardifier.CONTEXT_KEY, this chunk’s Beardifier | the constant 0.0 |
Bounds
Every node answers DensityFunction.range, an Interval, without a
position. Most take theirs from a child — cache, interpolated, slice
and blend_density all do — and no registered node stores one: BinaryFunction, UnaryFunction and ClampFunction combine their children’s ranges through Interval on every call, and
BlendedNoise derives its own from its parameters. The bounds of a parsed
graph are the ones the compiler reads: a noise’s range comes from its
definition, not its seed, and no rewrite changes a bound. What a bound is
worth is
the lecture’s argument;
this table is which node departs from its child, and how.
| id | its range | |
|---|---|---|
| add, sub, mul, div, min, max | interval arithmetic on the two children’s ranges | mul takes the least and greatest of the four products of the operands’ ends, a zero end giving zero even against infinity; div multiplies by the reciprocal’s range; min and max take the element-wise minimum and maximum. A min or max whose two ranges cannot overlap logs a warning when it is compiled, and only the side that wins whenever the inputs stay inside their declared ranges is sampled |
| abs, square | the child’s ends mapped, with zero as the minimum when the child’s range contains zero | Interval.abs and Interval.square map both ends |
| reciprocal | ±infinity whenever the child’s range straddles zero | the reciprocal has no finite bound across zero |
| clamp | the child’s range, clamped into min and max | a child already inside them keeps its own, narrower range |
| find_top_surface | its lower_bound and its upper bound’s maximum | Y coordinates — this node’s range is on a different scale from every other row |
| noise | its NormalNoise’s, fixed when the noise definition is decoded | the three shifts are ignored; the sixty-four shipped noise definitions come out between ±0.87 and ±5.73 |
DensityFunctions.HolderHolder, off the table, asks its target, and throws
while its holder is unbound; forward references parse anyway, because nothing
asks a node for its bound until the graph is compiled.
What vanilla actually uses
Fifty-five JSON files ship under worldgen/density_function — four at the top level, the rest in a directory each for the End, the Nether, the overworld and the overworld’s two preset variants — and between them they use
thirty-three of the forty-four ids. The seven Registries.NOISE_SETTINGS
files, the recipes terrain reads — two of them, caves and floating_islands, named by no shipped world preset — write no id the
density-function files lack. All seven final densities end by adding
beardifier, three of them in a file and four inline.
That leaves eleven ids vanilla data never writes. constant is never written
as a typed object, because a bare number is one. Nine are maths on a value: sqrt,
reciprocal, log, sign, pow and the four roundings. And shift — the
three-dimensional domain warp — is
used by nothing:
ShiftNoiseFunction.ShiftA and
ShiftNoiseFunction.ShiftB cover the two two-dimensional warps vanilla wants.
For a 1.21-era reader. NoiseChunk.wrapNew is gone:
DensityFunctionCompilercompiles each graph once per dimension, and each chunk supplies aSamplerContext. flat_cache, cache_2d, cache_once and cache_all_in_cell are gone, and cache does their work. shifted_noise is noise with shift fields, y_clamped_gradient is gradient on the Y axis, end_islands is end_outer_islands with the central island left to distance_to_point, and minValue and maxValue areDensityFunction.range.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Threads
Verified against Minecraft 26.3 · Reference · Hand-kept from
net/minecraft/util/threadand everyThreadthe game starts, beside Anatomy’s four — looked up, not watched.
Every thread the game creates, who creates it, what runs on it, and what is allowed to run on it. The last column is the rule the rest of the documentation leans on: game state belongs to exactly one thread, and anything else hands its work to that thread rather than doing it itself, apart from the unlocked readers and the Netty-side handlers this page names.
Three ways work crosses a thread boundary
Two threads own game state — the Render thread owns the client’s, the
Server thread owns the world’s — and everything else is a way of getting
work to them or from them. Work crosses a thread boundary in exactly three
ways, and the figure names which on every edge that is one of them: a posted task (a
Runnable handed to the executor that will run it — the owner’s BlockableEventLoop, a worker pool, or the connection’s Netty event loop, which is where Connection.sendPacket puts a packet sent from any other thread; a console line, left in a list the tick drains, is the same shape), a completed future (a
worker’s result, completed onto the owner’s executor), or a hopped
handler (a packet decoded on Netty and re-posted to its owner by
PacketUtils.ensureRunningOnSameThread).
flowchart TD
RT["Render thread: Minecraft.runTick"]:::client
NET["Netty IO: Connection"]:::netty
ST["Server thread: MinecraftServer.runServer"]:::server
WK["Worker-Main-n: Util.backgroundExecutor"]:::worker
IO["IO-Worker-n: Util.ioPool"]:::worker
SND["Sound engine: SoundEngineExecutor"]:::client
LST["console, RCON, management: dedicated only"]:::server
WD["Server Watchdog, query: dedicated only"]:::server
RT -- "posted task" --> NET
ST -- "posted task" --> NET
NET -- "play packets: hopped" --> RT
NET -- "play packets: hopped" --> ST
RT -- "posted task" --> WK
ST -- "posted task" --> WK
WK -. "completed future" .-> RT
WK -. "completed future" .-> ST
ST -- "posted task" --> IO
IO -. "completed future" .-> ST
RT -- "posted task" --> SND
LST -- "posted task" --> ST
WD -. "reads, unsynchronised" .-> ST
The threads that carry work, coloured by the side each serves, and every edge between them labelled with how work crosses it: a posted task out, a completed future back, a play packet hopped onto its owner, and the one edge that is none of the three, the watchdog and the query listener reading the Server thread’s state without a lock.
The daemon column is the one that decides how the process ends, because a
non-daemon thread holds the JVM open until something stops it. Six rows of
the table are non-daemon; on a dedicated server, which has no Render thread,
that is five, and only four of them can hold it open: ServerMain
returns the moment it has registered the shutdown hook, long before anything
needs stopping, which leaves the Server thread, Util.ioPool’s workers, and
the RCON and query listeners. The first two are retired
deliberately — the Server thread by returning, the IO pool by
Util.shutdownExecutors — and the other two are GenericThreads, stopped by clearing GenericThread.running and joining: the query listener polls its socket every half second and notices the flag, and RconThread.stop closes its socket first, so the RCON accept ends at once. Everything else in the table is a daemon and simply stops existing; Server Connector, Swing’s event dispatch thread, Realms’s threads and the chat filter’s workers the Server thread starts, among the situational threads below, are not (a worker a sign or book edit starts from Netty inherits Netty’s daemon flag), and nor are the two java.util.Timer threads the client’s notifications and the watchdog’s exit start
(how a server dies).
Every box in the figure has a row in the table — the listeners’ box has three, the watchdog’s two —
and two rows have no box,
because ServerMain has stopped existing before any of these edges are busy and
the timer-hack thread does nothing at all. Netty is drawn once and shared because
it is: in singleplayer the client’s Connection and the integrated
server’s run on the same Netty Local IO threads, and the packets between
them are real.
The threads a lecture leans on
| thread | made by | daemon | runs | may touch |
|---|---|---|---|---|
| Render thread (client) | the JVM main thread, renamed in client/main/Main | no — it is main | Minecraft.run → Minecraft.runTick once per frame; Minecraft.tick 0–10 times inside it | Everything client-side: ClientLevel, LocalPlayer, the GPU (RenderSystem.assertOnRenderThread), screens, options. It is also the client’s event loop (Minecraft is a ReentrantBlockableEventLoop), so packet handlers run here after PacketUtils.ensureRunningOnSameThread. |
| ServerMain (dedicated) | the jar’s bundler, which unpacks the libraries on the JVM’s main thread and starts this one | no | the whole of boot: the properties, the EULA, the ManagementServer, session.lock, the WorldLoader, then MinecraftServer.spin, which constructs the DedicatedServer before it starts the Server thread, and finally registering the Server Shutdown Thread as a JVM shutdown hook — which is a separate thread and not this one | Everything, until the Server thread exists; after that, nothing (starting a server) |
| Server thread | MinecraftServer.spin | no | MinecraftServer.runServer → MinecraftServer.processPacketsAndTick every TickRateManager.nanosecondsPerTick (50 ms by default); MinecraftServer.waitUntilNextTick drains the task queue in the slack | Every ServerLevel, every chunk, entity and block entity, the PlayerList. Serverbound play packet handlers run here, all but the ten in the table below and the three they inherit that never hop. One per server; singleplayer has exactly one. |
Netty IO (Netty NIO IO #n, Netty Epoll IO #n, Netty Kqueue IO #n, Netty Local IO #n) | EventLoopGroupHolder | yes | the Connection pipeline: split, decrypt, decompress, decode; encode, compress, encrypt — and the handshake and login handlers | Bytes and Packet objects — plus, in handshake and login, the handlers themselves: ServerHandshakePacketListenerImpl and ServerLoginPacketListenerImpl never hop. The login state machine is not all theirs, though: ServerLoginPacketListenerImpl is a TickablePacketListener, so the Server thread advances it once a tick (protocol phases walks that state machine) through MinecraftServer.tickConnection. A play handler that needs game state re-posts to the owning thread. Local is the in-process channel of singleplayer. |
| Worker-Main-n | Util.backgroundExecutor — a ForkJoinPool sized to the JDK’s available-processor count minus one, clamped by Util.maxAllowedExecutorThreads and capped by the max.bg.threads property (Util.getMaxThreads) | yes | chunk generation and lighting via ChunkTaskDispatcher; section meshing via SectionRenderDispatcher; resource-reload prepare phases; chunk serialisation | Its own inputs, and during generation the chunks its step may write — the one being generated and, while features are placed, its eight neighbours — the generated chunk’s first mobs included, which the Server thread builds again from their saved tags when the chunk goes full. Results return to the owning thread as a CompletableFuture completed onto that thread’s executor, or, for a mesh with geometry, through the upload the Render thread runs — a mesh with nothing to draw the worker installs itself. |
| IO-Worker-n | Util.ioPool | no | region file reads and writes through IOWorker, one PriorityConsecutiveExecutor per storage so writes to one file stay ordered | Files. Util.nonCriticalIoPool (Download-n) is a second pool of the same kind, for downloads, profile and account-service lookups, and sound decoding, and the one difference is this column: its threads are daemons, so nothing waits for them. |
| Sound engine | SoundEngineExecutor | yes | a BlockableEventLoop that owns the per-source OpenAL calls | The SoundEngine’s channels; see the sound engine. Device open/close and the deletion of cached static buffers stay on the Render thread. |
| Server Watchdog | DedicatedServer.initServer (dedicated only, positive limit only) | yes | ServerWatchdog | Writes nothing, but reads game state unsynchronised while the Server thread is mid-tick — the game rules and every level’s ServerLevel.getWatchdogStats, for the crash report. It kills the JVM past DedicatedServerProperties.maxTickTime, and with the tick wedged that kill does not save the world. |
| Server console handler | DedicatedServer.initServer | yes | reads stdin | Queues each line to the server thread as a command; runs nothing itself. |
| RCON Listener #n / RCON Client | RconThread.create from DedicatedServer.initServer, when enable-rcon is on and RconThread.create gets past its own gates: it returns nothing if rcon.port is outside 1–65535, if rcon.password is empty, or if the socket cannot be opened | no | accepts RCON sockets on a 500 ms timeout, though RconThread.stop closes the socket before it joins, so the accept ends at once; one RconClient thread per connection, which blocks with no timeout | Sockets. Each command is queued to the server thread. Dedicated only. DedicatedServer.onServerExit must stop it or the JVM will not exit (how a server dies). |
| Query Listener #n | QueryThreadGs4.create from DedicatedServer.initServer, when enable-query | no | the GS4 query protocol, on a 500 ms socket timeout, so it notices GenericThread.running going false within half a second | Its full-status reply, rebuilt at most every five seconds from the player count and names it reads off the live PlayerList without a lock; the basic reply reads the count afresh on every request. Dedicated only, and stopped by the same call as RCON. |
| Management server IO #n | ManagementServer, built by JsonRpc in server/Main | yes | a second, independent Netty event-loop group: the JSON-RPC/WebSocket management API and its heartbeat | Its own pipeline; management calls reach the game through the server’s task queue. Dedicated only. |
| Timer hack thread | Util.startTimerHackThread | yes | sleeps forever | Nothing. Keeps the JVM’s timer resolution high by existing. |
The handlers that never hop
Connection.channelRead0 calls a packet’s handler on the Netty thread, and
a handler’s first line is normally PacketUtils.ensureRunningOnSameThread,
which re-posts it to the owning thread and aborts
(the connection). The client’s play listener has nine handlers that omit it, two of them inherited; the server’s declares ten, and inherits three more that never hop (the keep-alive, the pong and the cookie response). They differ in kind: most of the client’s run to completion on Netty, and six of the server’s ten use the Netty thread only to hand the work somewhere else.
Nine on the client, then. Seven are declared in
ClientPacketListener itself; the last two are inherited from
ClientCommonPacketListenerImpl and are the two that matter most, because a keep-alive is answered, and a disconnect’s channel closed, from the Netty thread. Three rows reach the Render thread after all: the low-disk warning by a posted task, a keep-alive deferred to its next tick, and a disconnect, whose Connection.handleDisconnection that thread’s tick runs.
| handler | what it does on the Netty thread |
|---|---|
ClientPacketListener.handlePlayerCombatEnter | nothing — the body is empty |
ClientPacketListener.handlePlayerCombatEnd | nothing — the body is empty |
ClientPacketListener.handleChunkBatchStart | starts the ChunkBatchSizeCalculator’s clock |
ClientPacketListener.handleChunkBatchFinished | stops it and sends ServerboundChunkBatchReceivedPacket with the chunks-per-tick it now wants — so the loop in what the client is told times the batch’s arrival and decode on Netty, not mesh building |
ClientPacketListener.handleDebugSample | hands the sample to DebugScreenOverlay.logRemoteSample |
ClientPacketListener.handlePongResponse | records the round trip in PingDebugMonitor |
ClientPacketListener.handleLowDiskSpaceWarning | calls Minecraft.sendLowDiskSpaceWarning, which posts the toast to the Render thread itself — one of the three that cross after all, by Minecraft.execute rather than by the hop |
ClientCommonPacketListenerImpl.handleKeepAlive | replies with ServerboundKeepAlivePacket through ClientCommonPacketListenerImpl.sendWhen, deferred while the window is frozen at RenderSystem.isFrozenAtPollEvents — so the answer goes at once from the Netty thread unless the Render thread is stuck in the poll, and then waits for its next tick |
ClientCommonPacketListenerImpl.handleDisconnect | calls Connection.disconnect straight from the event loop, which closes the channel; the Render thread’s next tick finds it closed and runs Connection.handleDisconnection |
Ten on the server, out of ServerGamePacketListenerImpl’s sixty-one game
handlers — the other fifty-one open on the hop
(the server tick
has what that costs the tick). They fall into four kinds: two that touch nothing, four that post a
task, two that wait on a future, and two that write listener state on Netty
because nothing else can be asked to.
| handler | what it does on the Netty thread |
|---|---|
ServerGamePacketListenerImpl.handlePingRequest | sends ClientboundPongResponsePacket back with the time it arrived, and nothing else |
ServerGamePacketListenerImpl.handleCustomPayload | nothing — the body is empty |
ServerGamePacketListenerImpl.handleChat | advances the acknowledgement window under the LastSeenMessagesValidator’s lock, then ServerGamePacketListenerImpl.tryHandleChat: illegal characters disconnect, a hidden chat visibility answers with a system message, and everything else resets the last-action time and posts the work through MinecraftServer.execute |
ServerGamePacketListenerImpl.handleChatCommand | the same door without a signature — ServerGamePacketListenerImpl.tryHandleChat posts the parse and the run |
ServerGamePacketListenerImpl.handleSignedChatCommand | the same, the signed arguments collected inside the posted task |
ServerGamePacketListenerImpl.handleCustomCommandSuggestions | hands the request to the player’s ServerCommandSuggestionsProvider, which keeps only the latest and answers at most one a tick, posting the work through MinecraftServer.execute or leaving it for the listener’s own tick |
ServerGamePacketListenerImpl.handleSignUpdate | strips formatting and sends the four lines to the text filter; the result comes back on the server’s own executor |
ServerGamePacketListenerImpl.handleEditBook | the same shape for a book’s title and pages, and the same hand-back |
ServerGamePacketListenerImpl.handleChatAck | applies the offset to LastSeenMessagesValidator under a lock, and disconnects the player if it does not validate — real listener state, written off the main thread |
ServerGamePacketListenerImpl.handleConfigurationAcknowledged | swaps the inbound protocol and installs a ServerConfigurationPacketListenerImpl, so the return to configuration begins on the network thread (protocol phases) |
Situational threads
Real, but nothing in the corpus hangs on them: User Authenticator (one per login that asks the session server), Chat-Filter-Worker, Server Pinger and
Server Connector (the multiplayer screen), Telemetry-Sender,
LanServerPinger and its detector, World Upgrader, Datafixer Bootstrap
(priority 1, so it yields to everything), the client and server shutdown hooks, ClientShutdownWatchdog once the client is closing, Swing’s event dispatch
thread when a dedicated server is started without –nogui and runs its
MinecraftServerGui and its Server log monitor, Latency Simulator #n behind a debug latency flag, the Friends List fetcher the client starts when its friend list is on,
and ChaseServer’s two threads and ChaseClient’s one, which exist only
behind SharedConstants.DEBUG_CHASE_COMMAND and the /chase command it
registers. Realms starts eight more — seven in com/mojang/realmsclient and RealmsConnect’s connect task — and is out of scope with the rest of
com/mojang/realmsclient.
The rule the last column is stating
Two threads own game state and everything else is a way of getting work to
them: client state is the Render thread’s, server state is the Server thread’s,
and in singleplayer they share a JVM and still talk about the world only
through packets over the local channel
(anatomy has
the four to memorise, and the settings that do cross by direct call). Three
consequences run through the table and each is owned by a lecture, not here: a
play packet is decoded on Netty and handled on its owner’s thread
(the connection);
workers compute and owners commit, so a generated chunk is installed by the thread that owns it, never by the pool
(the level tick);
and an owning thread waiting on a future keeps draining its own queue through
BlockableEventLoop.managedBlock, which is why a tick that waits for a chunk
does not deadlock the chunk that needs the tick
(the server tick).
Where to look
Util.backgroundExecutor · Util.ioPool · Util.startTimerHackThread ·
MinecraftServer.spin · EventLoopGroupHolder · GenericThread ·
SoundEngineExecutor · ServerWatchdog ·
PacketUtils.ensureRunningOnSameThread · BlockableEventLoop.managedBlock
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Math and primitives
Verified against Minecraft 26.3 · Reference · The coordinate spaces, geometry and randomness every system page assumes — looked up, not watched.
Every system in the game speaks in a handful of value types: an integer
block position, a chunk column, a 16³ section, a double-precision world
position, a direction, a box, a collision shape, a random source. They are
the types the codebase reaches for most — BlockPos alone has 1,229 importers, more than any other Minecraft class — and most of what is confusing
about “which coordinate is this”
is answered by knowing which type a method takes.
The coordinate spaces
Six integer spaces and one double one, and every conversion is a named method.
The figure is the spaces from the finest to the coarsest, and the one that adds a dimension, each arrow the arithmetic of one step; the table under it names the method for each
arrow and, for all but GlobalPos, the way back, and the three that also pack to a long key are
the next section’s.
flowchart TD
V["Vec3: a double position"] -- "floor" --> B["BlockPos: one block"]
B -- "shift 2" --> Q["QuartPos: a 4-block cell"]
Q -- "shift 2" --> S["SectionPos: a 16-block cube"]
B -- "shift 4" --> S
S -- "drop y" --> C["ChunkPos: a 16-block column"]
C -- "shift 5" --> R["region: 32 chunks a side, one file"]
B -- "plus a dimension" --> G["GlobalPos"]
The coordinate spaces as a ladder of sizes: after the first step, which floors, every step down is a right shift by as many bits as the next space is coarser, except the one that drops the y axis and the one that adds a dimension; the table names the method for each.
| space | unit | type | owner / notes | conversions |
|---|---|---|---|---|
| block | 1 block, int | BlockPos (extends Vec3i) | immutable unless it is a BlockPos.MutableBlockPos, the loop subclass | BlockPos.containing floors a double position; BlockPos.asLong packs to a long and BlockPos.of unpacks one |
| world position | 1 block, double | Vec3 (implements Position) | entity positions, ray casts, velocities | Vec3.atCenterOf, Vec3.atLowerCornerOf, Vec3.atBottomCenterOf from a Vec3i; Vec3.directionFromRotation from pitch/yaw |
| chunk column | 16 blocks | ChunkPos — a record of x and z | packed to a long, the key of the chunk maps in ChunkMap and the ticket system | ChunkPos.containing from a BlockPos; ChunkPos.pack / ChunkPos.unpack; ChunkPos.getMinBlockX, ChunkPos.getWorldPosition |
| section | 16³ cube | SectionPos (extends Vec3i) | lighting, entity sections, render sections | SectionPos.of from block/chunk/entity; SectionPos.blockToSectionCoord (shift 4), SectionPos.sectionToBlockCoord, SectionPos.sectionRelative (mask 15); SectionPos.chunk drops y; SectionPos.asLong |
| quart / biome | 4 blocks | QuartPos (static only) | biome sampling | QuartPos.fromBlock (shift 2), QuartPos.toBlock, QuartPos.fromSection, QuartPos.toSection |
| region | 32 chunks | none — methods on ChunkPos | the .mca file grid | ChunkPos.getRegionX, ChunkPos.getRegionLocalX, ChunkPos.minFromRegion; ChunkPos.REGION_SIZE |
| dimension-qualified block | — | GlobalPos — record of a Level key and a BlockPos | compass targets, beds, a player’s last death location | GlobalPos.of |
| integer box | blocks | BoundingBox for structures; BlockBox is a newer record that nothing yet uses | structure bounds, piece placement | BoundingBox.intersects, BoundingBox.encapsulate; BlockBox.aabb, BlockBox.contains |
| double box | blocks | AABB (a class of six public final doubles) | entity bounding boxes, block shapes’ bounds | AABB.move, AABB.inflate, AABB.intersects, AABB.clip |
| pitch / yaw | degrees, float | Vec2 | look direction, as (xRot, yRot) — except the Rotation NBT tag, which stores it the other way round | Direction.fromYRot, Direction.toYRot |
| pose rotation | degrees, float ×3 | Rotations | ArmorStand poses, and nothing else | — |
| model / render space | float | JOML Vector3f, Matrix4f, Quaternionf (external) | everything under client/renderer | Vec3.toVector3f; Direction.step, Direction.getRotation; com/mojang/math Axis builds quaternions |
Position is the three-double interface Vec3 implements. Vec3i is the
int triple with the arithmetic — immutable to every caller that does not hold a BlockPos.MutableBlockPos, which the closer takes up — (Vec3i.offset,
Vec3i.relative, Vec3i.distSqr, Vec3i.distManhattan); BlockPos adds
the iteration helpers (BlockPos.betweenClosed, BlockPos.withinManhattan,
BlockPos.spiralAround), each of which walks a single reused
BlockPos.MutableBlockPos rather than allocating.
BlockPos.breadthFirstTraversal is the exception in the same class: it is not
an iterator at all, and allocates a queue of nodes and a set of visited longs.
Cursor3D is a different allocation-free box cursor — the one that also
classifies each position as inside, face, edge or corner
(Cursor3D.TYPE_INSIDE, Cursor3D.TYPE_FACE, Cursor3D.TYPE_EDGE,
Cursor3D.TYPE_CORNER) — and it is used by SectionPos, BlockCollisions
and ClientLevel, not by BlockPos.
Colours are a primitive too, and they live in net/minecraft/util: ARGB is
where every pack, unpack, lerp, multiply and alpha helper is, with
CommonColors for the named constants, ColorRGBA for the codec-friendly
value and Brightness for the packed block/sky light pair.
Three long keys
Three long-packings appear everywhere as map keys.
BlockPos.asLong— 26 bits X, 26 bits Z, 12 bits Y, high to low.BlockPos.PACKED_HORIZONTAL_LENGTHis derived from 30,000,000, the level’s hard horizontal bound (Level.MAX_LEVEL_SIZE), which is why it is 26 (BlockPos.MAX_HORIZONTAL_COORDINATEis 33,554,431); the remainingBlockPos.PACKED_Y_LENGTHis 12, giving −2048 to 2047. The usable range is narrower:DimensionTypereserves a 32-block margin, soDimensionType.MIN_Yis −2032,DimensionType.MAX_Yis 2031 andDimensionType.Y_SIZEis 4064.BlockPos.getFlatIndexmasks off the low four Y bits — it snaps a packed position to the bottom of its own 16-block section, which is the skylight walk-up idiom, not a per-column key (the top eight Y bits survive).BlockPos.STREAM_CODECsends the packed long;Vec3i.STREAM_CODECsends three varints.SectionPos.asLong— 22 bits X, 22 bits Z, 20 bits Y. A section-relative position packs into a short (SectionPos.sectionRelativePos).ChunkPos.pack— X in the low 32 bits, Z in the high 32.ChunkPos.INVALID_CHUNK_POSis a sentinel;ChunkPos.isValidis bounded byChunkPyramid.MAX_CHUNK_COORDINATE_VALUE, which lives with the chunk status pyramid because the safety margin is derived from how many neighbours generation reads.
Directions and symmetry
Direction is the six-valued enum in 3D-data order DOWN, UP, NORTH, SOUTH, WEST, EAST (Direction.get3DDataValue); its horizontal subset has its own
order starting at south (Direction.get2DDataValue). Direction.Axis (X, Y, Z),
Direction.AxisDirection and Direction.Plane (horizontal, vertical) are
the nested helpers; CompositeDirection.Direction8 the compass points; FrontAndTop the twelve
jigsaw and crafter orientations; AxisCycle the axis permutation
VoxelShape lookups use.
Block rotation is Rotation and Mirror (world/level/block), each of
which maps onto OctahedralGroup (com/mojang/math) — the 48-element
symmetry group of a cube, each element a permutation plus three inversion
flags. That group is not decoration: Shapes.rotate, Shapes.rotateAll,
Shapes.rotateHorizontal and Shapes.rotateAttachFace are how a block
declares one shape and is handed the rest — three more from
Shapes.rotateHorizontal, five from Shapes.rotateAll, and eleven from
Shapes.rotateAttachFace, which is Shapes.rotateHorizontal run once per
AttachFace. Transformation wraps a JOML
matrix and lazily decomposes it into translation, left rotation, scale and
right rotation for model JSON and a display entity’s transformation.
There are two things called Axis in scope: Direction.Axis and the
quaternion factory in com/mojang/math. They are unrelated, and a grep finds a
third in the save-migration tree, which this book does not cover.
Shapes and collision
A VoxelShape is a set of boxes on a per-axis coordinate grid, backed by a
DiscreteVoxelShape bit grid (BitSetDiscreteVoxelShape). Shapes is the
factory and algebra: Shapes.block, Shapes.empty, Shapes.box,
Shapes.or, Shapes.join with a BooleanOp, Shapes.joinIsNotEmpty,
Shapes.collide, Shapes.blockOccludes, with Shapes.EPSILON (1.0E-7) the tolerance the shape algebra’s own comparisons use and Shapes.BIG_EPSILON a coarser 1.0E-6; a collision context’s is above test uses a looser 1.0E-5. Implementations
differ by how the grid is stored — CubeVoxelShape (even divisions),
ArrayVoxelShape (explicit coordinate lists), SliceShape (a
one-cell-thick view, used for face culling and occlusion) — and Shapes.joinUnoptimized picks an IndexMerger strategy per axis, building a CubeVoxelShape only when all three merge evenly; Shapes.join is that followed by VoxelShape.optimize.
Context-free collision and face-sturdiness queries are cheap because they are mostly not computed (the outline, interaction and support shapes are asked of the block every time):
BlockBehaviour.BlockStateBase.initCache builds a
BlockBehaviour.BlockStateBase.Cache per block state holding the collision
shape, whether the collision shape reaches outside its cell, whether the collision shape is a full block,
and a per-face sturdiness array — but only for a block whose shape is not
dynamic, and the occlusion shape is not in it. That one is a field on
BlockBehaviour.BlockStateBase itself, built whether the cache is or not. Without a cache a block answers those queries live, and says it has a large collision shape whatever it has; and the query an entity’s movement makes, with its CollisionContext, is never cached — it asks the block every time.
CollisionContext is what a shape query knows about who is asking:
CollisionContext.of an entity (EntityCollisionContext — descending,
bottom Y, held item, whether fluids collide), CollisionContext.empty,
CollisionContext.placementContext — all three of which are
EntityCollisionContexts, as is the MinecartCollisionContext
CollisionContext.of returns for a minecart under the experimental movement
flag. The one case that is neither is PositionCollisionContext, from
CollisionContext.positionContext. Ray casts
return a HitResult: BlockHitResult (position, face, inside,
world-border) or EntityHitResult.
Two random families, a saved table, and a mixer
RandomSource (net/minecraft/util) is the interface. Most implementations
live in world/level/levelgen, and the legacy family shares BitRandomSource,
which defines RandomSource.nextInt and friends on top of a raw bit generator;
XoroshiroRandomSource implements RandomSource directly, and
RandomSequences keeps one more in world/. Two families coexist in one
process:
- Legacy LCG —
LegacyRandomSource(the java.util.Random algorithm),SingleThreadedRandomSource(same, no atomics),ThreadSafeLegacyRandomSource.RandomSource.createreturns aLegacyRandomSourcewith a uniquified seed; this isLevel.random(inherited unchanged byServerLevelandClientLevel),Entity.random,ParticleEngine.random.RandomSource.createThreadLocalInstancereturns aSingleThreadedRandomSourceand is whatClientLevel.animateTickuses for block animation, andLevelRendererfor the block-destroy overlay. - Xoroshiro —
XoroshiroRandomSource(128-bit state viaRandomSupport.Seed128bit), the one a noise settings file gets unless its required legacy_random_source flag asks for legacy:NoiseGeneratorSettings.getRandomSourcereturnsWorldgenRandom.Algorithm.XOROSHIROunless the settings opt into legacy. Four of the seven shipped noise settings do opt in — nether, end, caves and floating_islands — and the overworld is not one of them, nor are amplified and large_biomes. So an ordinary world runs both families at once: its overworld’s noise is Xoroshiro and the noise of the two dimensions you walk into through a portal is legacy, while every dimension carves, and chooses where its structures go, with a legacy source, and decorates with a Xoroshiro one.RandomStateforks the chosen algorithm positionally (PositionalRandomFactory.at,PositionalRandomFactory.fromHashOf) for the named noise consumers — the aquifer and the ore placer each get their own deterministic stream from the seed and position.
WorldgenRandom wraps any delegate and adds the seeding conventions —
WorldgenRandom.setDecorationSeed, WorldgenRandom.setFeatureSeed,
WorldgenRandom.setLargeFeatureWithSalt, WorldgenRandom.seedSlimeChunk —
that make a structure land in the same place for the same seed. Features go
through those, not through RandomState.
There is a third randomness path, on the Xoroshiro family: RandomSequence and RandomSequences, a saved, Identifier-keyed table of XoroshiroRandomSource
streams derived from the world seed, which is what makes a loot table and
/random reproducible across sessions. And two that are not a RandomSource at all: LinearCongruentialGenerator, the bare mixer BiomeManager uses for biome fuzzing, and Level.randValue, the integer Level.getBlockRandomPos steps for random-tick positions.
RandomSource.nextGaussian is produced by MarsagliaPolarGaussian, which caches a spare value — which is why reseeding a source resets it, in every implementation but the deprecated ThreadSafeLegacyRandomSource and WorldgenRandom, whose WorldgenRandom.setSeed reseeds only its delegate.
Mth and Util
Mth is the maths grab-bag (703 importers): Mth.floor, Mth.clamp,
Mth.lerp, Mth.wrapDegrees, Mth.rotLerp, Mth.smallestEncompassingPowerOfTwo,
Mth.log2, Mth.positiveModulo, Mth.hsvToRgb. Mth.sin and Mth.cos
are lookups in a 65,536-entry table (Mth.cos is the same table with a
quarter-turn phase shift), and the table is filled from the JDK’s ordinary
sine rather than its strict one — so the platform-dependent step, if
you are chasing animation determinism, is the table’s construction and not
the lookup. Util (in net/minecraft/util, 457 importers) is where the
executors live — Util.backgroundExecutor, Util.ioPool,
Util.nonCriticalIoPool — along with time sources and collection helpers;
Unit is the single-valued “void” type codecs and futures use. The other
numeric odds and ends worth knowing by name are CubicSpline (terrain
shaping), InclusiveRange, and BitStorage, the packed-integer array
underneath every palette.
What trips people up
ChunkPos is a record with no asLong. The names are ChunkPos.pack
and ChunkPos.unpack; construction from a block is ChunkPos.containing;
the components are accessed as x() and z().
Vec3i.toMutable returns a JOML Vector3i, not a
BlockPos.MutableBlockPos; the mutable block position comes from BlockPos.mutable or its constructor, and its BlockPos.MutableBlockPos.set /
BlockPos.MutableBlockPos.move are the loop idiom.
A BlockPos may be mutable. Vec3i keeps protected setters that BlockPos.MutableBlockPos, a subclass of BlockPos, makes public; every other subclass treats them as final, so a BlockPos reference can hold a position that moves. BlockPos.immutable is the copy to call before
storing a mutable one.
Level.random crashes when two threads draw at once.
LegacyRandomSource holds an atomic seed not for safety but as a
detector: a draw or a reseed whose compare-and-set loses to another thread’s write raises a ThreadingDetector exception, so concurrent use is caught when two uses collide and not otherwise. The genuinely safe variant,
ThreadSafeLegacyRandomSource, and RandomSource.createThreadSafe are both
deprecated. Touching a level’s random from a worker is loud only when it collides.
Tick randomness and worldgen randomness do not divide by family. The LCG drives every Level and Entity, the Nether’s and the End’s noise, and every dimension’s carvers and structure placement; the saved RandomSequences behind loot and /random are Xoroshiro, and so are the overworld’s noise and every dimension’s features.
PositionalRandomFactory.parityConfigString is implemented by both
families, so the parity dumps cover whichever one a dimension’s noise settings choose.
BlockBox is declared and unused. It is a tidy BlockPos-pair record
in net/minecraft/core, and in 26.3 nothing calls it; structure bounds are
still BoundingBox in world/level/levelgen/structure. Worth knowing
before assuming a rename happened.
Direction.getRotation treats UP as identity, and Direction.step
returns a fresh JOML vector while Direction.getUnitVec3f returns the read-
only shared one.
SectionPos has SectionPos.x as both an instance and a static method, instance and static, same name.
Not everything with Block in the name is in net/minecraft/core.
BlockBox, BlockMath and Cursor3D are; BlockUtil is in
net/minecraft/util (naming drift).
BlockMath is model-rotation plumbing, not coordinates.
Where to look
Vec3i · BlockPos · ChunkPos · SectionPos · QuartPos · GlobalPos ·
Cursor3D · Vec3 · AABB · BoundingBox · Direction · Rotation ·
OctahedralGroup · VoxelShape · Shapes · CollisionContext ·
HitResult · ARGB · Mth · RandomSource · BitRandomSource ·
WorldgenRandom · PositionalRandomFactory · RandomSequences · Util
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Level data and rules
Verified against Minecraft 26.3 · Reference · Who owns the seed, the spawn, the rules, the border and the dimensions, where each is saved, and what tells the client — looked up, not watched.
The facts about a world that are not blocks or entities: its seed and
dimensions, its spawn, its game time, its difficulty, its rules, its
border, its scoreboard and maps. In 26.3 about half of them have left
level.dat — PrimaryLevelData is a stub and everything else is
a SavedData file under a data/ folder, one server-global and one per
dimension — so the question this page answers is always the same one:
which file remembers this, and who is allowed to change it. The table
under who owns what is the page. Six sections follow it,
one for each datum whose answer is longer than a row — what is left in level.dat, with how a save happens inside it, the two saved-data storages, the rules, the border, the dimensions and the seed, and difficulty and weather — and a row with no section below it
is a row the table has already answered in full.
Five parts point here — III for what a boot reads, IV for what the world is made of, VIII for the two game rules that decide whether the server checks your movement, IX for the rule values a server sends a gamemaster’s client on request, and XII for the seed — and the level tick is where most of them are read.
Who owns what
In the saved as column, dimensions/…/ stands for dimensions/<namespace>/<path>/, the folder a dimension’s own id names; everything else is relative to the world folder.
| datum | owner | saved as | told to the client by |
|---|---|---|---|
| seed, structures, bonus chest, dimension list | WorldGenSettings (MinecraftServer.getWorldGenSettings) | data/minecraft/world_gen_settings.dat | the obfuscated seed in CommonPlayerSpawnInfo, the dimension list in ClientboundLoginPacket; structures and the bonus chest, nothing |
| world spawn | PrimaryLevelData.respawnData | level.dat | ClientboundSetDefaultSpawnPositionPacket — the effective spawn below on join, respawn and dimension change, the stored one when it is set |
| game time | PrimaryLevelData.gameTime (shared by every level) | level.dat | ClientboundSetTimePacket |
| day time | ServerClockManager | data/minecraft/world_clocks.dat | ClientboundSetTimePacket |
| difficulty, lock, hardcore | LevelSettings.DifficultySettings | level.dat | ClientboundChangeDifficultyPacket — hardcore alone rides in ClientboundLoginPacket |
| game type, allow-commands, name, data packs | LevelSettings | level.dat | the game type only as a new player’s default, through CommonPlayerSpawnInfo; the enabled features at the configuration phase; the name and allow-commands, nothing |
| game rules | GameRuleMap via GameRules | data/minecraft/game_rules.dat | five rules only, plus ClientboundGameRuleValuesPacket on request |
| weather | WeatherData | data/minecraft/weather.dat | ClientboundGameEventPacket |
| world border | WorldBorder, one per ServerLevel | dimensions/…/data/minecraft/world_border.dat | ClientboundInitializeBorderPacket and the five ClientboundSetBorder… packets |
| scoreboard | ServerScoreboard (MinecraftServer.scoreboard), buffered by ScoreboardSaveData at save time | data/minecraft/scoreboard.dat | ClientboundSetObjectivePacket, ClientboundSetScorePacket, ClientboundSetPlayerTeamPacket |
| maps | MapItemSavedData per MapId, MapIndex for the counter | data/minecraft/maps/<n>.dat, data/minecraft/maps/last_id.dat | ClientboundMapItemDataPacket |
| raids | Raids per level | dimensions/…/data/minecraft/raids.dat | boss bars |
| chunk tickets | TicketStorage per level (tickets) | dimensions/…/data/minecraft/chunk_tickets.dat | — |
| dragon fight | EnderDragonFight, where DimensionType.hasEnderDragonFight | dimensions/…/data/minecraft/ender_dragon_fight.dat | boss bars |
| boss bars, scheduled functions, random sequences, stopwatches, trader timers, command storage | CustomBossEvents, TimerQueue, RandomSequences, Stopwatches, WanderingTraderData, CommandStorage | data/minecraft/<id>.dat, and one data/<namespace>/command_storage.dat per namespace | boss bars only |
| player data | PlayerDataStorage | players/data/<uuid>.dat | — |
What is left in level.dat
LevelData is the read-only interface every Level exposes, and it is
small: LevelData.getRespawnData, LevelData.getGameTime,
LevelData.isHardcore, LevelData.getDifficulty and
LevelData.isDifficultyLocked. No day time — that has left level data
entirely for ServerClockManager (who owns the
clock) —
no weather, no rules, no border. LevelData.RespawnData is the world
spawn as a GlobalPos with yaw and pitch — it carries a dimension, so
/setworldspawn can point anywhere. WritableLevelData adds
WritableLevelData.setSpawn; ServerLevelData adds eight more — the level
name, the game type and its setter, ServerLevelData.isInitialized and its
setter, ServerLevelData.isAllowCommands and its setter, and
ServerLevelData.setGameTime; WorldData is the server-wide view
(WorldData.getLevelSettings, WorldData.getDataConfiguration,
WorldData.enabledFeatures, WorldData.wasModded,
WorldData.getKnownServerBrands, WorldData.overworldData).
PrimaryLevelData implements all of them — the overworld’s level data and
the whole server’s — with twelve fields: PrimaryLevelData.settings (a
LevelSettings: name, GameType, a LevelSettings.DifficultySettings of
difficulty, hardcore and lock, allow-commands, WorldDataConfiguration),
PrimaryLevelData.respawnData, PrimaryLevelData.gameTime,
PrimaryLevelData.initialized, PrimaryLevelData.knownServerBrands,
PrimaryLevelData.wasModded, PrimaryLevelData.removedFeatureFlags,
PrimaryLevelData.singlePlayerUUID, PrimaryLevelData.version,
PrimaryLevelData.specialWorldProperty (PrimaryLevelData.SpecialWorldProperty.FLAT
/ PrimaryLevelData.SpecialWorldProperty.DEBUG / none),
PrimaryLevelData.versionHistory (every data version that has saved the
world, written as version_history), and
PrimaryLevelData.worldGenSettingsLifecycle, the DFU Lifecycle that makes
the world-select screen warn about experimental settings. That is the whole
stub: seed, dimensions, rules, border, weather, dragon fight, boss bars,
scheduled events and trader timers are all SavedData files.
PrimaryLevelData.createTag builds the payload — flat, no wrapper — and
it is LevelStorageSource.LevelStorageAccess.saveDataTag that nests it
under Data; PrimaryLevelData.parse reads it back.
DerivedLevelData is what every other ServerLevel gets, built in
MinecraftServer.createLevels: spawn, game time and initialised state
forwarded to the overworld’s data, and everything the whole server shares —
name, game type, hardcore, allow-commands, difficulty and its lock — read
straight off the WorldData. Only DerivedLevelData.setSpawn writes
anything; DerivedLevelData.setGameTime, DerivedLevelData.setGameType,
DerivedLevelData.setAllowCommands and DerivedLevelData.setInitialized are
no-ops. That forwarding is how game
time comes to be shared by every level.
The file is written by LevelStorageSource.LevelStorageAccess.saveDataTag
→ LevelStorageSource.LevelStorageAccess.saveLevelData: a temp file, then
Util.safeReplaceFile renames the old level.dat to level.dat_old and
the temp into place, ten retries per step with a rollback.
One method saves all three
Nothing in the table above saves itself, and but for player data MinecraftServer.saveAllChunks is the single call behind every row. It does four things in order: it flushes
the live ServerScoreboard into its ScoreboardSaveData (which is what the
scoreboard row means by buffered at save time — the scoreboard is a live
object all tick and becomes saved data only here), saves every level’s chunks,
writes level.dat through LevelStorageSource.LevelStorageAccess.saveDataTag, and then either joins the
saved-data storage or schedules it. So the rows with no section below them —
day time, the scoreboard, maps, raids, chunk tickets, the dragon fight and the boss-bar row’s six owners — are saved by being SavedData, and that one flush is the only special case among them. Player data is the other road: PlayerDataStorage is not SavedData, and PlayerList.save writes a player’s file, both from PlayerList.saveAll and whenever the player leaves. MinecraftServer.saveEverything wraps MinecraftServer.saveAllChunks with PlayerList.saveAll in front, and that is the pair autosave and /save-all reach; shutdown calls the two itself.
LevelSummary — the world-select row — is read without opening the world at
all, by LevelStorageSource.readLevelSummary, and it is where the numbers on
that screen come from: a LevelVersion (LevelVersion.levelDataVersion,
LevelVersion.lastPlayed, LevelVersion.minecraftVersionName,
LevelVersion.minecraftVersion — the DataVersion the needs upgrading and
from a newer version warnings compare — and LevelVersion.snapshot) plus the
LevelSettings. Two subclasses stand for worlds it could not read:
LevelSummary.CorruptedLevelSummary, and LevelSummary.SymlinkLevelSummary for a world whose level.dat is a link to a place the allow list refuses.
Every path a world folder has a name for
LevelResource names thirteen paths under a world folder, and the per-player
files are three separate ones rather than one player data row:
LevelResource | the path | what is in it |
|---|---|---|
LevelResource.LEVEL_DATA_FILE | level.dat | what this page’s table calls level data |
LevelResource.OLD_LEVEL_DATA_FILE | level.dat_old | the previous one, kept by the rename dance above |
LevelResource.LOCK_FILE | session.lock | held for as long as the world is open (starting a server) |
LevelResource.DATA | data/ | the server-global SavedData files: the scoreboard, the game rules, the world-gen settings, the boss bars |
LevelResource.PLAYER_DATA_DIR | players/data/ | one .dat per player, read twice on join (players and sessions) |
LevelResource.PLAYER_ADVANCEMENTS_DIR | players/advancements/ | one JSON per player, the progress half of every advancement (advancements) |
LevelResource.PLAYER_STATS_DIR | players/stats/ | one JSON per player, and every statistic is also a scoreboard criterion (scores, teams and stored data) |
LevelResource.PLAYER_OLD_DATA_DIR | players/ | the parent of the three, and where the oldest worlds’ name-keyed player files sit until a dedicated server’s boot moves them, through OldUsersConverter, into players/data/ |
LevelResource.ROOT · LevelResource.ICON_FILE · LevelResource.GENERATED_DIR · LevelResource.DATAPACK_DIR · LevelResource.MAP_RESOURCE_FILE | . , icon.png, generated/, datapacks/, resourcepacks/resources.zip | the world folder itself, its world-select thumbnail, what a structure block saves (jigsaw and templates), the world’s own packs, and the world’s own resource pack, which the client loads when it opens the world |
Thirteen constants, and nothing else in a world folder has a name here: the dimensions/ tree is named by DimensionType.getStorageFolder, and the region files inside it by ChunkPos arithmetic (chunk storage).
The spawn every level reports is the server’s, not each level’s
ServerLevel.getRespawnData forwards to MinecraftServer.getRespawnData,
which returns MinecraftServer.effectiveRespawnData — recomputed by
MinecraftServer.updateEffectiveRespawnData through
Level.getWorldBorderAdjustedRespawnData, which relocates a spawn that
has fallen outside the border to the border centre’s surface, and by
MinecraftServer.findRespawnDimension, which falls back to the overworld
when the stored dimension no longer exists. So every level reports the same
spawn, and it need not be the one level.dat holds: it is the stored one wherever that is inside the border it is tested against — the overworld’s, when the stored dimension no longer exists — and a relocated one where it is not.
Two saved-data storages, neither of them the overworld’s
MinecraftServer.savedDataStorage (MinecraftServer.getDataStorage) is
server-global at <world>/data/; maps, scoreboard, rules, weather and
clocks are server-wide. ServerChunkCache.savedDataStorage
(ServerChunkCache.getDataStorage, forwarded by
ServerLevel.getDataStorage) is per dimension at
dimensions/<namespace>/<path>/data/ — the overworld included
(chunk storage); raids, tickets, the
border and the dragon fight are per dimension. The overworld’s per-dimension
files sit under dimensions/ like everyone else’s, so neither storage is
“the overworld’s”: one is the server’s and one is each dimension’s.
What a saved-data file is
SavedData is one flag, SavedData.dirty (SavedData.setDirty); a
SavedDataType is an id, a constructor, a Codec and a DataFixTypes.
SavedDataStorage caches them per folder
(SavedDataStorage.computeIfAbsent, SavedDataStorage.get,
SavedDataStorage.set) and writes each as { data, DataVersion },
gzip-compressed. How a dirty entry reaches the disk — encoded straight from the live object on the caller’s thread, with no copy first, written on the IO pool, and joined by a flush save, shutdown or the world upgrader — is half the bargain a chunk makes, and chunk storage is where that bargain is explained.
The id is an Identifier, so every saved-data file lives under a namespace
folder — the path is data/<namespace>/<path>.dat — and vanilla’s are
all under data/minecraft/. Command storage is the one place the namespace
is not minecraft: each gets its own data/<namespace>/command_storage.dat.
Game rules are a registry
GameRule is a registry entry in Registries.GAME_RULE /
BuiltInRegistries.GAME_RULE, bootstrapped by GameRules.bootstrap:
GameRule.category (a GameRuleCategory — GameRuleCategory.PLAYER,
GameRuleCategory.MOBS, GameRuleCategory.SPAWNING, GameRuleCategory.DROPS,
GameRuleCategory.UPDATES, GameRuleCategory.CHAT, GameRuleCategory.MISC),
GameRule.gameRuleType (GameRuleType.INT or GameRuleType.BOOL),
GameRule.argument (a Brigadier type), GameRule.valueCodec,
GameRule.defaultValue and GameRule.requiredFeatures. Ids are
snake_case and namespaceable — GameRules.ADVANCE_TIME, GameRules.SPAWN_MOBS,
GameRules.SPAWN_MONSTERS, GameRules.KEEP_INVENTORY,
GameRules.RANDOM_TICK_SPEED, GameRules.PLAYERS_SLEEPING_PERCENTAGE,
GameRules.RESPAWN_RADIUS, GameRules.MAX_ENTITY_CRAMMING,
GameRules.MAX_SNOW_ACCUMULATION_HEIGHT,
GameRules.MAX_MINECART_SPEED (feature-gated) … fifty-nine of them, with
their defaults, in the generated table; the old names and the
nested value classes have rows in naming drift.
The values are saved data, not level data: a GameRuleMap — SavedData,
game_rules.dat, server-global — wrapped by the GameRules instance in
MinecraftServer.gameRules. ServerLevel.getGameRules returns the
server’s: one set for every dimension, and Level has no rules
accessor at all, so no ClientLevel can read a rule — the client’s GameRules objects belong to the two AbstractGameRulesScreens, WorldCreationGameRulesScreen and InWorldGameRulesScreen, and to WorldCreationUiState, whose rules become the new world’s when it is created. The accessors are
GameRules.get, GameRules.set (which calls
MinecraftServer.onGameRuleChanged) and GameRules.visitGameRuleTypes
(how GameRuleCommand.register builds two literals per rule — the
bare id and the namespaced one).
What the client hears
Five rules are pushed, and the rest reach a client only when a gamemaster asks — as data through the editor below, or as the chat feedback of /gamerule. All three of
GameRules.REDUCED_DEBUG_INFO, GameRules.LIMITED_CRAFTING and
GameRules.IMMEDIATE_RESPAWN ride in ClientboundLoginPacket at join (the
last inverted, as showDeathScreen); a change afterwards goes as a
ClientboundEntityEventPacket for the first and a ClientboundGameEventPacket
for the other two (ClientboundGameEventPacket.LIMITED_CRAFTING,
ClientboundGameEventPacket.IMMEDIATE_RESPAWN); GameRules.LOCATOR_BAR through
ServerWaypointManager; and GameRules.ADVANCE_TIME, which broadcasts a
full clock sync because a paused clock is expressed on the wire as rate 0
(environment attributes).
One mechanism in this area sends the client nothing at all:
MinecraftServer.updateMobSpawningFlags, which pushes a flag down the
server’s own chain instead (below).
There is an in-game editor: ServerboundClientCommandPacket.Action.REQUEST_GAMERULE_VALUES
→ ServerGamePacketListenerImpl.sendGameRuleValues →
ClientboundGameRuleValuesPacket → InWorldGameRulesScreen, and edits
back as ServerboundSetGameRulePacket → ServerGamePacketListenerImpl.handleSetGameRule
(gated on Permissions.COMMANDS_GAMEMASTER).
The border is per dimension
The border has no lecture. It is the one mechanism in Part IV’s packages whose home is this page: it sits on no conveyor, it belongs to none of the four side-systems, and what a reader needs of it is a set of numbers, a pair of extents and a list of packets — which is what a Reference page is for. Part IV’s landing page declares it as such.
WorldBorder is SavedData — world_border.dat, per dimension,
fetched by ServerLevel.getWorldBorder through the cache on every call.
Nothing scales the Nether’s border by DimensionType.coordinateScale —
each dimension’s file has its own values, so the Nether has the same
numbers unless someone sets them.
WorldBorder.settings (WorldBorder.Settings: centre, damage per block,
safe zone, warning blocks and time, size, lerp time and target;
WorldBorder.Settings.DEFAULT is 0,0 / 0.2 / 5 / 5 / 300 / WorldBorder.MAX_SIZE)
is the loaded snapshot, never written again;
WorldBorder.applyInitialSettings pushes it into the live fields once, resuming a lerp in progress from where the save left it, and saving reads the live fields back out.
The live defaults are not the persisted ones — a fresh WorldBorder
starts with a warning time of 15, not 300.
The live extent is a WorldBorder.BorderExtent — WorldBorder.StaticBorderExtent
or WorldBorder.MovingBorderExtent, which WorldBorder.tick advances
(the level tick). A moving border marks itself for saving every tick: WorldBorder.MovingBorderExtent marks the
saved data dirty on every advance, and a stationary one never does.
WorldBorder.MAX_SIZE is 59,999,968; MinecraftServer.getAbsoluteMaxWorldSize
is applied to every level’s border in MinecraftServer.createLevels —
29,999,984, which is MinecraftServer’s own answer and so the integrated
server’s, while DedicatedServer overrides it with max-world-size. WorldBorder.isWithinBounds, WorldBorder.clampToBounds,
WorldBorder.getDistanceToBorder, WorldBorder.getCollisionShape are
the readers; BorderStatus colours the client’s wall.
For sync, PlayerList.addWorldborderListener registers a BorderChangeListener
per level that broadcasts, dimension-scoped, ClientboundSetBorderSizePacket,
ClientboundSetBorderLerpSizePacket, ClientboundSetBorderCenterPacket,
ClientboundSetBorderWarningDelayPacket and ClientboundSetBorderWarningDistancePacket;
PlayerList.sendLevelInfo sends ClientboundInitializeBorderPacket on
join and dimension change. ClientLevel.worldBorder is a plain
WorldBorder ticked in ClientLevel.tick.
Dimensions and the seed
DimensionType is a record of sixteen components, and they fall into four
kinds:
| kind | components | |
|---|---|---|
| the shape of the space | DimensionType.minY, DimensionType.height, DimensionType.logicalHeight, DimensionType.coordinateScale | how tall, how deep, how high a portal or a chorus fruit may reach, and what a coordinate becomes when you step through a portal |
| what the sky does | DimensionType.hasSkyLight, DimensionType.hasCeiling, DimensionType.hasFixedTime, DimensionType.ambientLight, DimensionType.skybox (a DimensionType.Skybox), DimensionType.cardinalLightType | the six that decide whether there is daylight, a roof over it, whether it ever counts as day or night, and what is drawn where the blocks stop |
| what may live here | DimensionType.monsterSettings, DimensionType.infiniburn, DimensionType.hasEnderDragonFight | the two monster-spawn light tests (DimensionType.MonsterSettings.monsterSpawnLightTest and DimensionType.MonsterSettings.monsterSpawnBlockLightLimit), the block tag that burns for ever, and the dragon fight as a flag rather than hard-wired to Level.END |
| the three that are a system of their own | DimensionType.attributes (an EnvironmentAttributeMap), DimensionType.timelines, DimensionType.defaultClock (a WorldClock holder; WorldClocks.OVERWORLD, WorldClocks.THE_END) | where the gameplay booleans a 1.21-era reader will look for on this record have gone (the stack a value falls through); naming drift has the row for each |
DimensionType.getStorageFolder names the on-disk folder.
Defaults are DimensionDefaults (DimensionDefaults.OVERWORLD_MIN_Y −64,
DimensionDefaults.OVERWORLD_LEVEL_HEIGHT 384,
DimensionDefaults.NETHER_LOGICAL_HEIGHT 128); the built-in keys are
BuiltinDimensionTypes.OVERWORLD, BuiltinDimensionTypes.NETHER,
BuiltinDimensionTypes.END, BuiltinDimensionTypes.OVERWORLD_CAVES.
LevelStem is a DimensionType holder plus a ChunkGenerator
(LevelStem.OVERWORLD, LevelStem.NETHER, LevelStem.END).
Registries.LEVEL_STEM and Registries.DIMENSION share the id
dimension (identifiers and registries);
Level.OVERWORLD, Level.NETHER, Level.END are ResourceKeys under
the latter, and Level.dimension / Level.dimensionType are the
accessors. Level.canHaveWeather is sky light, no ceiling, not the End.
The seed and the dimension list are WorldGenSettings — SavedData,
world_gen_settings.dat, server-global, and written not at world creation but
by the server’s first save, the MinecraftServer constructor having pushed it
into the storage, as it does on every boot (creating a world is where it
comes from) — holding
WorldOptions
(WorldOptions.seed, WorldOptions.generateStructures,
WorldOptions.generateBonusChest) and WorldDimensions (the stem map;
WorldDimensions.bake produces the registry). It is read before the
server exists by LevelStorageSource.getLevelDataAndDimensions and
pushed in with SavedDataStorage.set; if the file is missing or
unreadable the loader logs an error and substitutes a whole default
WorldGenSettings — a random seed, structures on, no bonus chest, and the
data packs’ dimension list in place of the saved one — then carries on loading
the world. MinecraftServer.createLevels makes one
ServerLevel per stem; MinecraftServer.levelKeys go out in
ClientboundLoginPacket, the dimension type via registry sync, and the
biome-zoom-obfuscated seed in CommonPlayerSpawnInfo.
Difficulty and weather
Difficulty (Difficulty.PEACEFUL … Difficulty.HARD) sits in
LevelSettings.DifficultySettings. MinecraftServer.setDifficulty writes
it, then calls MinecraftServer.updateMobSpawningFlags and
MinecraftServer.sendDifficultyUpdate → ClientboundChangeDifficultyPacket.
Peaceful does not turn the spawner off. The flag
MinecraftServer.updateMobSpawningFlags pushes down — Level.setSpawnSettings, forwarded to
ServerChunkCache.setSpawnSettings, one boolean per level — is
ServerLevel.isSpawningMonsters, and that reads two game rules,
GameRules.SPAWN_MOBS and GameRules.SPAWN_MONSTERS, and never the
difficulty. Difficulty is spent on the mobs themselves instead:
EntityType.isAllowedInPeaceful gates each spawn attempt, and
Mob.checkDespawn discards a mob that is not allowed in peaceful on its next
despawn check. So the two paths are independent — GameRules.SPAWN_MOBS empties the spawner and GameRules.SPAWN_MONSTERS its hostile half, while peaceful empties the world — and MinecraftServer.setDifficulty calls
MinecraftServer.updateMobSpawningFlags only because a rule may have changed under it since
the last call.
DedicatedServer.forceDifficulty applies server.properties at boot
with the lock ignored; there is no getForcedDifficulty.
DifficultyInstance — local difficulty — is built by
ServerLevel.getCurrentDifficultyAt from ChunkAccess.getInhabitedTime,
Level.getOverworldClockTime and the moon phase (an environment
attribute, EnvironmentAttributes.MOON_PHASE, indexed into
DimensionType.MOON_BRIGHTNESS_PER_PHASE — the one piece of the old moon
logic still on DimensionType; see
environment attributes).
What reads it is Part VI: every Mob.finalizeSpawn is handed one
(entity lifecycle),
which is why a zombie that can break doors is a roll made once, at spawn, in
the chunk you have been standing in
(AI).
WeatherData — server-global SavedData, MinecraftServer.getWeatherData
— was covered in the level tick.
PrimaryLevelData stores no rain fields.
Where to look
PrimaryLevelData.setTagData · DerivedLevelData · LevelSettings ·
LevelStorageSource.getLevelDataAndDimensions ·
LevelStorageSource.LevelStorageAccess.saveDataTag · SavedDataStorage.scheduleSave ·
SavedDataType · MinecraftServer.createLevels · MinecraftServer.getDataStorage ·
ServerChunkCache.getDataStorage · GameRules.bootstrap · GameRules.set ·
MinecraftServer.onGameRuleChanged · GameRuleMap · GameRuleCommand.register ·
WorldBorder.applyInitialSettings · WorldBorder.tick ·
PlayerList.addWorldborderListener · DimensionType · LevelStem ·
WorldGenSettings · WorldDimensions.bake · MinecraftServer.setDifficulty ·
ServerLevel.getCurrentDifficultyAt
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Naming drift
Verified against Minecraft 26.3 · Reference · The translation layer: the names a 1.21-era reader will reach for that 26.3 dropped, moved or repurposed, and what it is called now.
Rule three of this corpus is newest version only: no page says “in 1.21 this was…”, because version-difference prose is the first thing to rot and the last thing anyone rereads. That rule costs something, and this page is where the cost is paid back once. Every page assumes you are reading the 26.3 tree; this page assumes you are not, yet, and are still typing the names you learned somewhere else.
Two audiences. A reader coming from 1.21 — the version most public writing, most tutorials and most model weights are anchored to — needs the version tables: the old name on the left, what to grep for on the right. A reader coming from Yarn (Fabric’s community mappings) needs the last table: not a version difference at all, just a different name for the same class in the same version.
The one sentence: if a name in your head does not appear in the tree, it is probably on this page.
How to read the tables
The left column is italic, not backticked. Most of these names do not
exist in 26.3, and tools/verify_names.py — which checks every backticked
identifier on every page against the decompile — would reject the page if
they were. Italics is the corpus’s mark for a name, but not a 26.3 name. The right column’s class and member names are backticked and therefore verified: those names are in the tree; its italics are file paths, save-file keys and old names. The left column is a name a 1.21-era reader may remember, and 1.21.11 — the last of them, and the only one this project can open — is the one the rows are checked against: most rows record drift that had already happened by 1.21.11, so a reader coming from 1.21.11 will find those left names missing from their own tree too.
“gone” in the right column means no class replaced the name one for one: the responsibility moved into something structurally different or was dropped, and the entry names where it went when it went somewhere. Those are the interesting rows — a rename is a nuisance, a disappearance is a design change, and the page named beside each part explains it.
The rows are the names the corpus’s own pages needed, so the table is not exhaustive — not even over the corpus: a few names the For a 1.21-era reader notes use, such as ItemBlockRenderTypes and LootItemFunctionType, have no row. Three hundred and nine rows, and the distribution is itself a finding: the three biggest tables are commands (41), rendering (39) and items (38), and the fourth is the server (31). The client was rewritten around extract-then-render, which is why almost nothing at the top of the render stack kept its name — but the permission rewrite and the game-rule registry moved more names than the renderer did.
The four you will hit in the first ten minutes
Identifier is ResourceLocation. Lightmap is LightTexture.
DeltaTracker is Timer, and partialTick is now a DeltaTracker.Timer
you ask rather than a float you are handed. And Gui no longer means the
HUD: the HUD is Hud, held as Gui.hud, while Gui is the screen and
overlay manager that also owns Gui.screen and Gui.setScreen — so a
1.21-era Minecraft.setScreen call site is now on Gui. Both Gui and
Hud exist, which is the single most confusing pair of names in the tree.
The tables
Everywhere
| the name you remember | 26.3 |
|---|---|
| ResourceLocation | Identifier |
| LightTexture | Lightmap |
| Timer | DeltaTracker |
Part II — Foundations
| the name you remember | 26.3 |
|---|---|
| TagManager | gone |
| Minecraft.reloadResources | Minecraft.reloadResourcePacks |
| ItemStack.save / parse | ValueOutput / ItemStack.CODEC |
| ChunkPos.asLong | ChunkPos.pack / ChunkPos.unpack (a record) |
| CompositePackResources | OverlayedPackResources |
| VanillaPackResources as a pack, VanillaPackResources.asProvider | a holder of FixedPathPackResources layers, VanillaPackResources.asResourceManager |
| SimpleJsonResourceReloadListener.scanDirectory | gone — data-pack registry files are read by RegistryDataLoader |
| RegistryDataLoader.WORLDGEN_REGISTRIES / RegistryLayer.WORLDGEN | RegistryDataLoader.WORLD_REGISTRIES / RegistryLayer.WORLD, beside a fourth list, RegistryDataLoader.RELOADABLE_REGISTRIES |
| RegistryCodecs.homogeneousList | RegistryCodecs.holderSet |
| HolderOwner.canSerializeIn | HolderOwner.canSerialize, asked of the context rather than the holder |
| BuiltInRegistries.BLOCK_TYPE / BlockTypes / Block.codec | gone — a block has no codec |
| PlacementModifierType / StructurePlacementType / BlockStateProviderType | gone — BuiltInRegistries.PLACEMENT_MODIFIER_TYPE, BuiltInRegistries.STRUCTURE_PLACEMENT and BuiltInRegistries.BLOCK_STATE_PROVIDER_TYPE hold the MapCodecs |
Part III — The server
| the name you remember | 26.3 |
|---|---|
| DO_DAYLIGHT_CYCLE | GameRules.ADVANCE_TIME |
| DO_MOB_SPAWNING | GameRules.SPAWN_MOBS |
| DO_WEATHER_CYCLE | GameRules.ADVANCE_WEATHER |
| GameRules package | world/level/gamerules |
| GameRules.Key<T> / GameRules.Value / BooleanValue / IntegerValue / GameRules.Type (all nested) | top-level GameRule, with GameRuleType, GameRuleTypeVisitor, GameRuleMap for the values and GameRuleCategory for the grouping |
| game rules as a hard-coded map | a registry — Registries.GAME_RULE / BuiltInRegistries.GAME_RULE, bootstrapped by GameRules |
| level.dat field GameRules, ids camelCase and unnamespaced | field game_rules, ids namespaced (minecraft:advance_time) — the whole rename table is GameRuleRegistryFix |
| doEntityDrops | GameRules.ENTITY_DROPS |
| doImmediateRespawn | GameRules.IMMEDIATE_RESPAWN |
| doInsomnia | GameRules.SPAWN_PHANTOMS |
| doLimitedCrafting | GameRules.LIMITED_CRAFTING — the name survives elsewhere, as a component of ClientboundLoginPacket and a field on LocalPlayer |
| doPatrolSpawning / doTraderSpawning / doWardenSpawning | GameRules.SPAWN_PATROLS / GameRules.SPAWN_WANDERING_TRADERS / GameRules.SPAWN_WARDENS |
| doVinesSpread | GameRules.SPREAD_VINES |
| enableCommandBlocks and commandBlocksEnabled | one rule, GameRules.COMMAND_BLOCKS_WORK |
| spawnerBlocksEnabled | GameRules.SPAWNER_BLOCKS_WORK |
| commandModificationBlockLimit | GameRules.MAX_BLOCK_MODIFICATIONS |
| minecartMaxSpeed | GameRules.MAX_MINECART_SPEED |
| snowAccumulationHeight | GameRules.MAX_SNOW_ACCUMULATION_HEIGHT |
| spawnRadius | GameRules.RESPAWN_RADIUS |
| disableElytraMovementCheck | GameRules.ELYTRA_MOVEMENT_CHECK — inverted |
| disablePlayerMovementCheck | GameRules.PLAYER_MOVEMENT_CHECK — inverted |
| disableRaids | GameRules.RAIDS — inverted |
| doFireTick + allowFireTicksAwayFromPlayer (two booleans) | one integer, GameRules.FIRE_SPREAD_RADIUS_AROUND_PLAYER (0 none, 128 near players only, −1 everywhere) |
| spawnChunkRadius, entitiesWithPassengersCanUsePortals, gameLoopFunction | gone with no replacement — the fix simply deletes them |
| day time on ServerLevel | ServerClockManager (world/clock) |
| weather on the overworld’s level data | server-global WeatherData |
| GameProfile on the player lists | NameAndId (a record of UUID and name) — PlayerList.canPlayerLogin, PlayerList.isWhiteListed, PlayerList.op, the ban/op/whitelist files |
| ServerPlayer.sendAllPlayerInfo / sendActivePlayerEffects | PlayerList.sendAllPlayerInfo / PlayerList.sendActivePlayerEffects |
| ServerLevelData.getScheduledEvents, the overworld’s level data holding the queue | MinecraftServer.getScheduledEvents, returning a server-wide TimerQueue saved data, advanced only by the overworld’s ServerLevel.tickTime |
| ServerLevel.updateSkyBrightness reading day time | the method survives, declared on Level, and now reads EnvironmentAttributes.SKY_LIGHT_LEVEL through EnvironmentAttributeSystem |
| ChunkMap.forEachBlockTickingChunk meaning block-ticking | it walks the entity-ticking set; the name did not follow the split |
Part IV — The world
| the name you remember | 26.3 |
|---|---|
| ChunkStorage | gone — ChunkMap extends SimpleRegionStorage |
| ChunkSerializer | SerializableChunkData, a record with its own parse and write halves — and a blending_data component |
| DimensionDataStorage | SavedDataStorage (one for the server, one per level) |
| getLightBlock | BlockBehaviour.BlockStateBase.getLightDampening |
| PalettedContainer.Strategy | top-level Strategy + Configuration |
| ForcedChunksSavedData | TicketStorage |
| TicketType<T> | a registry record with flag bits |
| DimensionType booleans | EnvironmentAttributeMap |
| DimensionType.ultraWarm | attributes the Nether sets, EnvironmentAttributes.FAST_LAVA and EnvironmentAttributes.WATER_EVAPORATES chief among them |
| DimensionType.piglinSafe | EnvironmentAttributes.PIGLINS_ZOMBIFY — inverted |
| DimensionType.bedWorks | EnvironmentAttributes.BED_RULE, a BedRule record, not a boolean |
| DimensionType.hasRaids | EnvironmentAttributes.CAN_START_RAID |
| DimensionType.natural | EnvironmentAttributes.NETHER_PORTAL_SPAWNS_PIGLINS and neighbours |
| DimensionType.fixedTime | DimensionType.hasFixedTime, a bare boolean — the time itself moved to WorldClock and Timelines.OVERWORLD_DAY |
| DimensionType.ambientLight | unchanged; one of the three visual fields that did not become an attribute, with DimensionType.skybox and DimensionType.cardinalLightType |
| Schedule (the villager’s) | EnvironmentAttributes.VILLAGER_ACTIVITY on Timelines.VILLAGER_SCHEDULE |
| Level.getDayTime / ServerLevel.setDayTime | ServerClockManager, keyed by WorldClock |
| data/<id>.dat | data/<namespace>/<path>.dat — every saved-data file gained a namespace folder |
| ChunkStatus.NOISE / SURFACE / CARVERS | ChunkStatus.TERRAIN, one task doing all three |
| ProtoChunk.carvingMask (saved with the chunk) | gone — the CarvingMask lives for one terrain run |
| BiomeManager.NoiseBiomeSource | BiomeResolver, which ChunkAccess implements |
Part V — Blocks
| the name you remember | 26.3 |
|---|---|
| ItemInteractionResult | gone — InteractionResult.TryEmptyHandInteraction |
| DirectionProperty | gone — EnumProperty<Direction> |
| Level.markAndNotifyBlock | gone — inline in Level.setBlock |
| BlockBehaviour.onRemove | BlockBehaviour.affectNeighborsAfterRemoval + BlockEntity.preRemoveSideEffects |
| doTileDrops | GameRules.BLOCK_DROPS |
| BlockModelShaper | BlockStateModelSet / BlockModelSet |
| RenderShape.ENTITYBLOCK_ANIMATED | gone — RenderShape.INVISIBLE / RenderShape.MODEL only |
| Player.canInteractWithBlock | Player.isWithinBlockInteractionRange |
| Block.rebuildCache | gone — BlockBehaviour.BlockStateBase.initCache from the Blocks static init |
| Material (the block property) | gone — individual BlockBehaviour.Properties flags. The name survives on a different class; see survived and changed meaning below |
| BlockEntity.saveToItem | BlockItem.setBlockEntityData + BlockEntity.collectComponents |
| MobEffects.DIG_SPEED / DIG_SLOWDOWN | MobEffects.HASTE / MobEffects.MINING_FATIGUE |
| RedStoneWireBlock | RedstoneWireBlock; its connection rule is each block’s BlockBehaviour.shouldRedstoneWireConnectTo |
| BedBlock.OCCUPIED / BedBlock.PART | AbstractBedBlock.OCCUPIED / AbstractBedBlock.PART |
| LevelEvent.PARTICLES_DESTROY_BLOCK as the break event (2001) | LevelEvent.PARTICLES_AND_SOUND_DESTROY_BLOCK; the old name is now 2014, particles only |
Part VI — Entities
| the name you remember | 26.3 |
|---|---|
| Player extends LivingEntity | Player extends Avatar extends LivingEntity |
| EntityType.PIG (constants) | EntityTypes.PIG + EntityTypeIds.PIG |
| BlockUtil in core | BlockUtil, in net/minecraft/util — the name did not move, the package did |
| MobSpawnType | EntitySpawnReason (+ EntitySpawnRequest) |
| SpawnPlacements.Type | SpawnPlacementType / SpawnPlacementTypes |
| Entity.hurt(DamageSource, float) returning a boolean | split into Entity.hurtServer and Entity.hurtClient. Both old shapes survive as deprecated finals — Entity.hurt delegating to the server half, Entity.hurtOrSimulate as the boolean-returning successor — so grep still finds the name |
| doMobLoot | GameRules.MOB_DROPS |
| LivingEntity.isDamageSourceBlocked | gone — DataComponents.BLOCKS_ATTACKS |
| Schedule / ScheduleBuilder | gone — Timeline + EnvironmentAttribute |
| BlockPathTypes | PathType |
| LivingEntity.brainProvider | LivingEntity.makeBrain(Brain.Packed) |
| Entity.moveTo / absMoveTo | Entity.snapTo / Entity.absSnapTo |
| Entity.maxUpStep (field) | Attributes.STEP_HEIGHT |
| Entity.updateFluidHeightAndDoFluidPushing | EntityFluidInteraction |
| Entity.lerpTo | Entity.moveOrInterpolateTo + InterpolationHandler |
| EntityDataSerializers.OPTIONAL_UUID / COMPOUND_TAG | gone |
| UUID-keyed AttributeModifier | Identifier-keyed record |
| AttributeMap.getDirtyAttributes | AttributeMap.getAttributesToSync + AttributeMap.getAttributesToUpdate |
| PlayerRenderer | AvatarRenderer (serves players and mannequins, keyed by skin model) |
| Entity.invulnerableTime as the hurt cooldown | LivingEntity.damageCooldownTime; Entity.invulnerableTime is a separate full-invulnerability timer |
| Entity.hurtMarked | Entity.syncVelocity |
| TryFindWaterGoal | TryFindLiquidGoal |
Part VII — Items and inventories
| the name you remember | 26.3 |
|---|---|
| InteractionResultHolder | gone — InteractionResult.Success.heldItemTransformedTo |
| UseAnim | ItemUseAnimation |
| Item.getFoodProperties | DataComponents.FOOD on the stack |
| ItemStack.getTag / getOrCreateTag | gone — components |
| LivingEntity.triggerItemUseEffects | Consumable.emitParticlesAndSounds |
| FoodProperties effects list | Consumable.onConsumeEffects |
| ClickType | ContainerInput |
| MultiPlayerGameMode.handleInventoryMouseClick | MultiPlayerGameMode.handleContainerInput |
| ClientboundSetCarriedItemPacket | split: ClientboundSetCursorItemPacket + ClientboundSetHeldSlotPacket |
| ClientboundSetSlotPacket | ClientboundContainerSetSlotPacket |
| ClientboundHorseScreenOpenPacket | ClientboundMountScreenOpenPacket |
| Container.startOpen(Player) | Container.startOpen(ContainerUser) |
| Recipe.getResultItem / getIngredients | Recipe.assemble / PlacementInfo. The first is gone outright; the second is off the Recipe interface and survives only as a test-visible method on ShapedRecipe |
| Ingredient.EMPTY | gone — Ingredient.CODEC rejects an empty literal list, but a tag that resolves to nothing still yields an empty one, hence Ingredient.isEmpty |
| ClientboundUpdateRecipesPacket carrying recipes | property sets + the stonecutter input set; the book gets RecipeDisplayEntrys |
| net.minecraft.advancements.CriteriaTriggers | CriteriaTriggers, moved to net/minecraft/advancements/triggers |
| Player.permissionLevel / hasPermissions(int) | Player.permissions → a PermissionSet, queried by named Permissions keys |
| ServerboundPlayerCommandPacket.Action.PRESS_SHIFT_KEY / RELEASE_SHIFT_KEY | gone — sneak rides ServerboundPlayerInputPacket → Entity.setShiftKeyDown |
| Mannequin on the client | ClientMannequin, installed by swapping the mutable Mannequin.constructor factory at client startup |
| data/<ns>/recipes/ | data/<ns>/recipe/ (singular) |
| EnchantmentCategory | Enchantment.EnchantmentDefinition item sets |
| Enchantment.getDamageBonus, EnchantmentHelper.getFireAspect… | gone — EnchantmentEffectComponents |
| EnchantedBookItem | gone — DataComponents.STORED_ENCHANTMENTS |
| Item.getEnchantmentValue | DataComponents.ENCHANTABLE |
| LootContextParam / LootContextParamSet | ContextKey / ContextKeySet (util/context) |
| LootDataManager / LootTables | ReloadableServerRegistries + BuiltInLootTables |
| LootTableReference | NestedLootTable |
| LootingEnchantFunction | EnchantedCountIncreaseFunction |
| SetCountFunction | SetItemCountFunction |
| LootContextParams.KILLER_ENTITY | LootContextParams.ATTACKING_ENTITY |
| AxeItem / ShovelItem / HoeItem | gone — DataComponents.BLOCK_TRANSFORMER on a plain Item |
| RecipeManager as a reload listener | Registries.RECIPE, read by RegistryDataLoader; the RecipeManager constructor builds the RecipeMap |
| LootPoolSingletonContainer | UniformContainerBase |
| LootItemFunctions.compose, lists of functions and conditions | one optional modifier per level, and one optional condition on a pool, an entry or a conditional function; several functions are one SequenceFunction |
| LootItemConditions | LootItemConditionTypes |
| NumberProvider | split in two: ContextFloatProvider and ContextIntProvider |
| ContextAwarePredicate / ConditionReference | gone — a Holder of a LootItemCondition |
| LootContext.getParameter / getOptionalParameter | LootContext.getOptional |
Part VIII — The player
| the name you remember | 26.3 |
|---|---|
| Inventory.armor / offhand / compartments | one 36-slot Inventory.items, and the armour and offhand in the entity’s EntityEquipment through Inventory.EQUIPMENT_SLOT_MAPPING |
| Inventory.setPickedItem | Inventory.addAndPickItem / Inventory.pickSlot |
| Entity.moveTo | Entity.absSnapTo / Entity.snapTo |
| GameRenderer.pick | Minecraft.pick → LocalPlayer.raycastHitResult |
| ServerboundInteractPacket.ActionType.ATTACK | ServerboundAttackPacket (a record of one int) |
| GameRules.NATURAL_REGENERATION | GameRules.NATURAL_HEALTH_REGENERATION |
| isCritArrow / Player.sweepAttack | Player.canCriticalAttack / Player.isSweepAttack + Player.doSweepAttack, all three private. isCritArrow was never a Player method and is still live on AbstractArrow |
| LivingEntity.eat / Player.eat | gone — Consumable.onConsume → FoodProperties → FoodData.eat |
| MobEffect.createModifier | MobEffect.createModifiers (plural) |
| ServerboundSwingPacket | ServerboundPunchPacket up, with no fields; ClientboundSwingAnimationPacket down |
| ServerGamePacketListenerImpl.handleAnimate | ServerGamePacketListenerImpl.handlePunch |
| LocalPlayer.swing | gone — Minecraft.startAttack and Minecraft.continueAttack send the punch |
| LivingEntity.swinging / swingingArm / swingTime / attackAnim | LivingEntity.SwingState |
| ItemStack.getSwingAnimation, DataComponents.SWING_ANIMATION | ItemStack.getAttackAnimation + ItemStack.getInteractAnimation, DataComponents.ATTACK_ANIMATION + DataComponents.INTERACT_ANIMATION |
Part IX — Networking
| the name you remember | 26.3 |
|---|---|
| Connection.setListener / setProtocol / getCurrentProtocol | gone — Connection.setupInboundProtocol / Connection.setupOutboundProtocol |
| ConnectionProtocol.getById / packet tables | gone — a bare enum; ids are ProtocolInfoBuilder.addPacket order in IdDispatchCodec |
| Connection.NETWORK_WORKER_GROUP etc. | EventLoopGroupHolder (in server/network) |
| MemoryConnection | gone — Connection.isMemoryConnection |
| ensureRunningOnSameThread(…, BlockableEventLoop) | PacketUtils.ensureRunningOnSameThread with a PacketProcessor |
| Packet.write(FriendlyByteBuf) | gone — a per-packet StreamCodec constant the protocol reads |
| ClientboundAddPlayerPacket / ClientboundAddMobPacket | gone — ClientboundAddEntityPacket |
| ClientboundUpdateViewPositionPacket | ClientboundSetChunkCacheCenterPacket |
| ClientboundUpdateViewDistancePacket | ClientboundSetChunkCacheRadiusPacket |
| ClientboundLevelChunkPacket | ClientboundLevelChunkWithLightPacket |
| routine ClientboundTeleportEntityPacket | ClientboundEntityPositionSyncPacket |
| ClientboundGameProfilePacket | ClientboundLoginFinishedPacket (+ a session id) |
| ServerboundLoginStartPacket | ServerboundHelloPacket |
| ClientboundEncryptionRequestPacket / response | ClientboundHelloPacket / ServerboundKeyPacket |
| ClientboundSetCompressionPacket | ClientboundLoginCompressionPacket |
| ClientboundResourcePackPacket | ClientboundResourcePackPushPacket / …PopPacket |
| MinecraftServer.getSessionService | MinecraftServer.services |
| PlayerChunkSender in server/level | server/network |
| Component.Serializer (Gson) | ComponentSerialization (codecs; NBT on the wire) |
| TextComponent / TranslatableComponent / … | network/chat/contents — PlainTextContents etc. |
| ComponentUtils.updateForEntity | ComponentUtils.resolve with a ResolutionContext |
| SignedMessageHeader / MessageSigner | SignedMessageLink / SignedMessageChain.Encoder |
| ChatPreview and its packets | gone |
| ClientboundSetTimePacket(gameTime, dayTime, …) | a game time plus a WorldClock update map |
| FriendlyByteBuf.readList / readMap / readCollection / limitValue | gone — ByteBufCodecs.list, ByteBufCodecs.map and ByteBufCodecs.collection, which clamp the allocation |
| FriendlyByteBuf.readBlockHitResult | BlockHitResult.STREAM_CODEC |
Part X — The client
| the name you remember | 26.3 |
|---|---|
| Gui (the HUD) | Hud, held as Gui.hud; the name Gui now means the screen/overlay manager |
| Minecraft.screen / Minecraft.setScreen | Gui.screen / Gui.setScreen |
| GuiGraphics | GuiGraphicsExtractor (records states; does not draw) |
| Screen.render / every render* on Gui | Screen.extractRenderState / every extract* on Hud |
| LayeredDraw | bounding-box inference and call order, plus GuiRenderState.nextStratum |
| Options.hideGui | Hud.isHidden, published as GuiRenderState.isHudHidden |
| Minecraft.getPartialTick, Timer | DeltaTracker.Timer and its three questions |
| Minecraft.destroy | gone — Minecraft.stop, then Minecraft.exitWorldAndClose and Minecraft.close |
| key-mapping categories as strings | KeyMapping.Category is a registrable record, not a string |
| MouseHandler.lastMouseEventTime | gone |
raw (key, scancode, modifiers, action) on every Screen method | the client/input records: KeyEvent, MouseButtonEvent, CharacterEvent, PreeditEvent |
| Font.drawInBatch and every drawString variant | Font.prepareText → Font.PreparedText; the drawing verbs are on GuiGraphicsExtractor, drawString’s as GuiGraphicsExtractor.text |
| Font.StringRenderOutput | Font.PreparedText plus Font.GlyphVisitor |
| BakedGlyph (a class) | an interface; the sheet implementation is BakedSheetGlyph, effects are EffectGlyph |
| RawGlyph / SheetGlyphInfo | UnbakedGlyph (info and bake) and GlyphBitmap (pixels and upload) |
| GlyphProviderBuilder / GlyphProviderBuilderType | GlyphProviderDefinition / GlyphProviderType |
| Style.withFont taking an id | still Style.withFont, but the type is FontDescription, which may be a sprite rather than a font |
| FontSet.getGlyph as public API | private — FontSet.source then GlyphSource.getGlyph |
| AbstractSoundInstance.resolve | AbstractSoundInstance.getOrResolve |
| GLFW and its callbacks | SDL3: SDLEventHandler.pollEvents drains its events inside RenderSystem.pollEvents, input to KeyboardHandler and MouseHandler, the rest to Window.handleEvent |
| KeyboardHandler.setup / MouseHandler.setup | gone — SDLEventHandler calls the handlers’ public methods |
| LocalPlayer.tick sending the move packets | LocalPlayer.sendChanges, called by Minecraft.tick after the level’s entities and block entities |
Part XI — Rendering
Thirty-nine rows. Four refactors account for more than half of them — extract then render, the GPU layer’s move into com/mojang/renderpearl, the window’s move to SDL, and order-independent transparency — and the rest are the renames and moves around them — models, particles, sections, the camera, and the sky and fog.
| the name you remember | 26.3 |
|---|---|
| MultiBufferSource / BufferSource | SubmitNodeCollector / SubmitNodeStorage / FeatureRenderDispatcher |
| ShaderInstance, RenderStateShard | RenderPipeline + RenderPipelines + BindGroupLayouts |
| VertexBuffer, Tesselator, BufferUploader | GpuBuffer / GpuBufferSlice, BufferBuilder → MeshData (over a ByteBufferBuilder’s memory), UberGpuBuffer |
| RenderSystem.setShader / enableBlend / depthMask … | fields of a RenderPipeline |
| VertexFormat.Mode, VertexFormat.IndexType, TextureFormat | PrimitiveTopology, IndexType, GpuFormat |
| Window.updateDisplay, vsync as a swap interval | GpuSurface.present, vsync as a GpuSurface.PresentMode |
| LightTexture.pack and friends | LightCoordsUtil |
| DimensionSpecialEffects | DimensionType.skybox + EnvironmentAttributes + Timeline |
| FogParameters, RenderSystem.setShaderFogColor | FogData, RenderSystem.setShaderFog (a uniform slice) |
| Level.getSkyColor, ClientLevel.getStarBrightness, ClientLevel.effects | EnvironmentAttributeProbe.getValue on an EnvironmentAttribute |
| LevelRenderer.renderLevel / renderSky / renderChunkLayer | LevelRenderer.render and the LevelRenderer.addSkyPass family of frame-graph passes |
| LevelRenderer.blockChanged / setSectionDirty / allChanged | the same names on LevelExtractor |
| ChunkRenderDispatcher, RenderChunk, CompiledChunk | SectionRenderDispatcher, its SectionRenderDispatcher.RenderSection, CompiledSectionMesh |
| RenderType.chunkBufferLayers, ChunkSectionLayer.TRIPWIRE | ChunkSectionLayer, with three layers — the tripwire layer is gone |
| BakedModel, ModelResourceLocation | BlockStateModel / ItemModel; block models keyed by BlockState |
| BlockModelShaper, ItemModelShaper, BlockRenderDispatcher, ItemRenderer | BlockStateModelSet, ItemModelResolver, ModelBlockRenderer, and for the item draw ItemFeatureRenderer |
| BlockElement / BlockElementFace, AtlasSet, ItemColors | CuboidModelElement / CuboidFace, AtlasManager, ItemTintSource |
| EntityRenderer.render, RenderLayer.render | EntityRenderer.extractRenderState + EntityRenderer.submit |
| TextureSheetParticle, sheet ParticleRenderTypes | SingleQuadParticle + SingleQuadParticle.Layer |
| ParticleGroup (a limit record) | ParticleLimit; ParticleGroup is now the per-render-type bucket |
| Camera.setup | Camera.update + Camera.extractRenderState |
| Camera.getPosition, getEntity, getRotation, getLookVector | Camera.position, Camera.entity, Camera.rotation, Camera.forwardVector — the get prefix dropped from these four, though Camera.getNearPlane and Camera.getFluidInCamera kept theirs and new getters such as Camera.getCullFrustum and Camera.getFov carry one |
| RenderStateShard composition (the texture and layering half) | RenderType over a RenderPipeline, catalogued in RenderTypes, built by RenderSetup |
| BakedQuad as four vertices | a ten-component record, with a BakedQuad.MaterialInfo of eight |
| LiquidBlockRenderer | FluidRenderer, over a FluidModel |
| ItemOverrides / getPropertyOverride | SelectItemModel / RangeSelectItemModel / ConditionalItemModel |
| ScreenManager (the Blaze3D monitor manager) | MonitorManager, with Monitor and VideoMode — same package, now fed by SDL’s display events |
| Window.updateVsync | a GpuSurface.PresentMode in the surface configuration |
| GpuDevice, CommandEncoder, RenderPass in com/mojang/blaze3d | still interfaces, reworked and moved to com/mojang/renderpearl/api, implemented by FrontendGpuDevice and its siblings over the backends in com/mojang/renderpearl/backend/opengl and /vulkan |
| GLX | gone — RenderSystem.initBackendSystem starts SDL, and a backend that fails throws BackendCreationException |
| Window.isMinimized / Monitor.getPreferredVidMode | Window.isIconified / Monitor.getPreferredVideoMode |
| ItemInHandRenderer | FirstPersonHandsAndItems (the tick state and the extract) + FirstPersonHandsAndItemsRenderer (the pose) |
| NameTagFeatureRenderer | gone — a name tag is a TextFeatureRenderer.Submit |
| the transparency post chain (Fabulous) | order-independent transparency inside the main pass: LevelRenderer.executeOit, three OitStages |
| LevelRenderer.addCloudsPass / addWeatherPass | gone — clouds and weather draw inside LevelRenderer.addMainPass |
| ShaderManager.prepare, moj_import | ShaderManager.loadConfigs, #include resolved by GlslCompiler.compileToSpv |
| ShaderManager.CompilationCache | PipelineCache, swapped in by RenderSystem.setCurrentPipelineCache |
| GameRenderer.postEffectId / effectActive | GameRenderer.spectatedEntityPostEffect / GameRenderer.spectatedEntityEffectActive, beside the per-frame GameRenderer.requestedPostEffects |
| DynamicUniforms / DynamicUniformStorage | DynamicGpuData / DynamicGpuDataStorageMapped |
Part XII — World generation
| the name you remember | 26.3 |
|---|---|
| GenerationStep.Carving | gone — BiomeGenerationSettings.carvers is one flat HolderSet |
| DensityFunctions.WeirdScaledSampler | IntervalSelectFunction |
| StructureFeature / ConfiguredStructureFeature | Structure / Registries.STRUCTURE |
| Feature.RANDOM_PATCH, Feature.FLOWER | gone — composed from SimpleBlockFeature + placement |
| Feature.POINTED_DRIPSTONE / DRIPSTONE_CLUSTER | SpeleothemFeature / SpeleothemClusterFeature |
| AbstractTreeGrower and its subclasses | one final TreeGrower with constants |
| TreeConfiguration.dirtProvider | TreeFeature.belowTrunkProvider |
| Biome.BiomeCategory / Biome.getDownfall | gone |
| MultiNoiseBiomeSource.Preset | MultiNoiseBiomeSourceParameterList.Preset |
| BiomeSpecialEffects.fogColor / skyColor / music / ambient sound | EnvironmentAttributes.* via Biome.getAttributes |
| the +8 chunk population offset | gone — decoration starts at the chunk corner, InSquarePlacement scatters |
| StructureTemplateManager’s folder for saved templates, generated/<namespace>/structures/ | generated/<namespace>/structure/, the singular a data pack’s templates were already read from |
| level.dat field WorldGenSettings | its own file — WorldGenSettings is SavedData, written to data/minecraft/world_gen_settings.dat; PrimaryLevelData.OLD_WORLD_GEN_SETTINGS is all that is left of the key |
| ConfiguredFeature / FeatureConfiguration / FeaturePlaceContext | gone — a Feature carries its own parameters; its instances are Registries.FEATURE |
| PlacedFeature.placeWithBiomeCheck / PlacementModifier.getPositions | FeaturePlacer.placeWithBiomeCheck / PlacementModifier.modify |
| RandomOffsetPlacement | OffsetPlacement |
| ConfiguredWorldCarver / CarverConfiguration / NetherWorldCarver | gone — WorldCarver instances in Registries.CARVER; the nether carver is a cave carver |
| SurfaceRules / SurfaceSystem | MaterialRule / MaterialCondition / MaterialSystem |
| OreVeinifier | OreVeinRule |
| NoiseChunk.forChunk / NoiseChunk.wrapNew and the four cache markers | gone — DensityFunctionCompiler compiles once per dimension; one cache node; a SamplerContext per run |
| DensityFunctions.Marker / BlendAlpha / BlendOffset / BeardifierMarker / Mapped / Ap2 / MulOrAdd | CacheFunction and InterpolatedFunction, SimpleDensityFunction, UnaryFunction and BinaryFunction, under world/level/levelgen/densityfunction |
| TreeConfiguration | TreeFeature, a record holding the fields |
| Climate.findSpawnPosition | NoiseSpawnFinder.findSpawnPosition |
| Biome.getMobSettings | EnvironmentAttributes.NATURAL_MOB_SPAWNS |
| RandomState.sampler | RandomState.createClimateSampler |
Part XIII — Commands and data packs
The permission rewrite is the largest single break in this table: the
integer permission level is gone from the whole command API, replaced by
PermissionSet and PermissionCheck in net/minecraft/server/permissions.
The ints survive only in ops.json, in server.properties and on the wire.
| the name you remember | 26.3 |
|---|---|
| ResourceLocationArgument | IdentifierArgument (the registry id is unchanged) |
| CommandSourceStack.hasPermission(int) | CommandSourceStack.permissions + PermissionSet.hasPermission |
| CommandSourceStack.getPermissionLevel | gone — a source carries a PermissionSet. PermissionLevel itself is very much alive: LevelBasedPermissionSet, server.properties, ServerOpListEntry and the JSON-RPC schema all still speak it |
| CommandSourceStack.withPermission(int) | CommandSourceStack.withPermission taking a PermissionSet |
| SharedSuggestionProvider.hasPermission(int) | gone — the interface extends PermissionSetSupplier |
| Commands.LEVEL_GAMEMASTERS as an int | same name, now a PermissionCheck |
| Commands.hasPermission(int) | Commands.hasPermission taking a PermissionCheck, returning a PermissionProviderCheck |
| ServerPlayer.hasPermissions(int) | ServerPlayer.permissions |
| MinecraftServer.getProfilePermissions returning an int | the same name returning a LevelBasedPermissionSet |
| MinecraftServer.getFunctionCompilationLevel | MinecraftServer.getFunctionCompilationPermissions |
| Commands.LEVEL_ALL / LEVEL_MODERATORS / LEVEL_ADMINS / LEVEL_OWNERS as ints | all four are PermissionChecks too — PermissionCheck.AlwaysPass for the first, PermissionCheck.Require for the rest |
| ServerPlayer.setPermissionLevel(int) | PlayerList.sendPlayerPermissionLevel on the server; LocalPlayer.setPermissions on the client |
| ColorArgument | TeamColorArgument, yielding a TeamColor rather than a ChatFormatting |
| PlayerTeam.getColor returning a ChatFormatting | returns an optional TeamColor, its own enum carrying a TextColor |
| TestFunctionArgument / TestClassNameArgument | gone — /test addresses tests as registry ids through ResourceSelectorArgument and TestFinder |
| net.minecraft.advancements.Criterion / CriterionTrigger / SimpleCriterionTrigger | all moved to net/minecraft/advancements/triggers; CriterionTriggerInstance and CriterionProgress stayed behind in net/minecraft/advancements |
| ServerOpListEntry.getLevel | ServerOpListEntry.permissions |
| ParserUtils.parseJson | gone — SnbtGrammar plus ParserBasedArgument |
| ItemInput.createItemStack(int, boolean) | ItemInput.createItemStack with one argument; the guard is GiveCommand.MAX_ALLOWED_ITEMSTACKS |
| ServerFunctionManager.ExecutionContext (nested) | top-level ExecutionContext in net/minecraft/commands/execution |
| CommandFunction.Entry / CommandEntry / FunctionEntry | gone — a line is a BuildContexts.Unbound, a macro line a MacroFunction.MacroEntry |
| CommandFunction.CacheableFunction (nested) | top-level CacheableFunction, codec-backed |
| Commands.performCommand returning a success count | returns nothing; results are a CommandResultCallback pair |
| data/<ns>/functions/, data/<ns>/tags/functions/ | singular — function/ and tags/function/ |
| maxCommandChainLength | GameRules.MAX_COMMAND_SEQUENCE_LENGTH |
| maxCommandForkCount | GameRules.MAX_COMMAND_FORKS |
| announceAdvancements | GameRules.SHOW_ADVANCEMENT_MESSAGES |
| net.minecraft.advancements.critereon / criterion | split three ways: net/minecraft/advancements/triggers, net/minecraft/advancements/predicates, and advancements/predicates/entity for the entity half |
| AdvancementList | AdvancementTree (+ AdvancementNode, AdvancementHolder) |
| FrameType | AdvancementType |
| CriterionTrigger.addPlayerListener / removePlayerListener | gone — triggers are stateless; subscriptions live in PlayerAdvancements |
| LootContextParamSet | ContextKeySet |
| @GameTest, @GameTestGenerator, @BeforeBatch, @AfterBatch | gone — GameTestInstance in Registries.TEST_INSTANCE |
| GameTestRegistry / TestFunction | gone — Registries.TEST_FUNCTION + TestFunctionLoader, and TestData |
| a test’s batch as a string | GameTestInstance.batch — the batch is a TestEnvironmentDefinition, in one dimension |
| the structure block as the test host | TestInstanceBlock / TestInstanceBlockEntity |
| ServerAdvancementManager as a reload listener | Registries.ADVANCEMENT, read by RegistryDataLoader; the manager’s constructor lays the tree out |
| AdvancementTree.Listener | ClientAdvancements.Listener |
| Advancement.read | Advancement.STREAM_CODEC |
| EntityPredicate.ADVANCEMENT_CODEC | gone — LootItemCondition.CODEC |
| PlayerList.allowCommandsForAllPlayers | gone — IntegratedServer.getProfilePermissions answers for singleplayer first, with a guest command-access toggle |
The shape changes, not just the names
A rename table flatters the reader: it suggests that if you learn the three hundred and nine rows above you can read the tree. You cannot, because many of those rows are one of seven design changes, and the change is what the corresponding page is about:
- Tags on an item became components. ItemStack.getTag /
getOrCreateTag have no replacement; a stack is an
Itemplus aPatchedDataComponentMapand every former tag key is aDataComponentTypeinDataComponents— data components, items and stacks. - Hand-written serialisation became codecs. Packet.write is gone: every packet but the bundle carries a
StreamCodecthe protocol table reads, and most packets are records. ItemStack.save is gone: there isItemStack.CODECand, for saved data, theValueOutputfaçade — packets and stream codecs, codecs, NBT and JSON. - Rendering split into extract and render. GuiGraphics,
EntityRenderer.render, LevelRenderer.renderLevel, MultiBufferSource
and every
RenderSystemstate setter are gone or repurposed, because the frame now copies the live game into render state and then draws from that copy, both halves on the Render thread — the frame, Blaze3D, entity rendering. - Per-dimension and per-biome constants became one attribute system.
DimensionSpecialEffects and most of BiomeSpecialEffects are gone;
fog, sky, water fog colour, ambient sound and music are
EnvironmentAttributes resolved through anEnvironmentAttributeProbeover a stack of layers — lightmap, fog and sky, biomes. - Enums of behaviour became registries of data. EnchantmentCategory, GenerationStep.Carving and Biome.BiomeCategory are all gone, replaced by item sets, registry records,
HolderSets or nothing at all; MobSpawnType and BlockPathTypes stayed enums under new names. - UUIDs became identifiers. An
AttributeModifieris keyed byIdentifier, not a UUID, which is why a data pack can now name one — attributes. - Sides split.
Entity.hurtsplit intoEntity.hurtServerandEntity.hurtClient— the old name survives, as a@Deprecatedfinal that returns void and forwards to the server half only, so grep finds it and it does nothing on the client; Player.attack is still there but the packet that reaches it isServerboundAttackPacket, a record of one integer, andServerboundInteractPacketis right-click only. The general rule: where 1.21 had one method that checkedLevel.isClientSide, 26.3 tends to have two methods — damage and death, the sword swing.
Yarn
Yarn is Fabric’s community mapping set. It is not a different version of the game and nothing on this list is a change: it is the same 26.3 class under the name a Fabric modder has in their head. Only the ones that trip people are listed — where the Yarn and Mojang names differ enough that grep fails.
Both columns are italic here, because Yarn names are not in the decompile
and verify_names.py cannot check them; the Mojang column is the one this
corpus uses everywhere else, and every one of those names is backticked and
verified on its own page.
| Yarn | Mojang (this corpus) |
|---|---|
| World / ServerWorld / ClientWorld | Level / ServerLevel / ClientLevel |
| WorldChunk | LevelChunk |
| MinecraftClient | Minecraft |
| PlayerEntity / ServerPlayerEntity / ClientPlayerEntity | Player / ServerPlayer / LocalPlayer |
| ClientPlayNetworkHandler | ClientPacketListener |
| ServerPlayNetworkHandler | ServerGamePacketListenerImpl |
| ClientConnection | Connection |
| Text / MutableText | Component / MutableComponent |
| TextRenderer | Font |
| TextHandler | StringSplitter |
| TextVisitFactory | StringDecomposer |
| OrderedText | FormattedCharSequence |
| StringVisitable | FormattedText |
| FontStorage | FontSet |
| GlyphAtlasTexture | FontTexture |
| DrawContext | GuiGraphicsExtractor — and in 26.3 it does not draw; see the drift table |
| NbtCompound / NbtList / NbtElement | CompoundTag / ListTag / Tag |
| RegistryEntry / RegistryEntryList | Holder / HolderSet |
| Registries / RegistryKeys | BuiltInRegistries / Registries |
| Vec3d | Vec3 |
| Box | AABB |
| Hand | InteractionHand |
| ActionResult | InteractionResult |
| Inventory (the interface) | Container |
| PlayerInventory | Inventory |
| ScreenHandler / ScreenHandlerType | AbstractContainerMenu / MenuType |
| StatusEffect / StatusEffectInstance | MobEffect / MobEffectInstance |
| EntityAttribute / EntityAttributeInstance | Attribute / AttributeInstance |
| ParticleEffect | ParticleOptions |
| BlockPos.Mutable | BlockPos.MutableBlockPos |
| Identifier | Identifier — Yarn was right first; Mojang renamed to match |
The last row is the joke that keeps giving: the single most-cited example of “Yarn names are better” stopped being an example, and a decade of Fabric code now compiles against a Mojang-named class with the Yarn name.
What a rename table cannot tell you
- A verified name is not a correct claim.
verify_names.pyproves the right-hand column exists; it cannot prove the left-hand column ever did. The 1.21 side of this table, like the Yarn column above and the italic names in the pages’ For a 1.21-era reader notes, is what the corpus cannot verify, and 1.21.11 is the only 1.21 it can check a name against. - The names did not move where you would guess. Rendering is only the second-largest table, behind commands. Two rewrites nobody advertised — permissions ceasing to be integers, and game rules becoming a registry — renamed more identifiers than the render-stack refactor did, and the render one is the famous half only because its classes are the ones tutorials name.
- Renames cluster with rewrites. Where a name changed, the responsibility often moved too — though not always: ClickType is
ContainerInputwith the same constants, and RedStoneWireBlock isRedstoneWireBlockwith the same methods. Reading the row is not enough, which is what the linked page is for. - Two subsystems have no rows at all, and that is the answer. Dialogs
(
net/minecraft/server/dialogand its client screens) and the JSON-RPC management server postdate 1.21 entirely: there is no old name to look up, and a reader who cannot find one is not missing a row. Game tests are the opposite case — the whole 1.21 API is gone, which is why they have five. - Minecraft.setScreen is a trap rather than a rename.
Minecraft.setScreenAndShowexists in 26.3 and a 1.21-era reader grepping for the old name will land on it, then wonder why the screen stack behaves differently. The method that replaced the old one isGui.setScreen. - One name a 1.21 reader reaches for was never a class. There is no DebugPackets type in 26.3 and there is nothing it was renamed to: the debug traffic is a subscription protocol with one packet per kind (debugging the running game), not a static sender, so the grep that finds nothing is telling the truth.
- Some names survived and changed meaning, which is worse than a
rename because grep still finds them:
Gui(now the screen manager, not the HUD),Material(now a texture reference inclient/resources/model/sprite, not a block property),ParticleGroup(now a per-render-type bucket, not a count limit),Strategy(now top-level, not nested inPalettedContainer), andMultiVariant, whose name survives only in the data-generator package while the runtime type is gone.
Where to look
Identifier, then Holder and HolderSet (net/minecraft/core), then DataComponents. After that, pick the
part you are lost in and read its page rather than its rows.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Glossary
Verified against Minecraft 26.3 · Reference · A short entry per term the rest of the corpus uses, and a link to the page that owns it.
Every page in this corpus assumes the vocabulary of the pages before it. That is deliberate — a lecture that redefines “chunk holder” every time it appears is unwatchable — but it means a reader who arrives in the middle has no way back. This page is the way back: the terms, alphabetically, a short entry each, each pointing at the page where the term is actually explained.
An entry here is a reminder, not a definition to rely on. If the entry is all you needed, good; if it is not, the link is the point of the entry. Where a term is a class name, the class name is the entry — this corpus uses Mojang’s names for concepts as well as for types, and inventing a second vocabulary to sit beside them would only double the work.
A
Activity — the filter that decides which of a brain’s behaviours may
start at all, rather than a mode it runs in: the active set is always the core
activities plus exactly one other, and an ActivityData declares each one’s
prioritised behaviour list. → AI
Advancement — a data-pack-defined goal: criteria, a requirements expression over them, an optional display entry and a reward. → advancements
Aquifer — the worldgen component that decides what a point is made of once the density is known — stone, air, water or lava — from its own barrier and fluid-level noises; a carver only marks a shape, and the aquifer decides what fills each block of it. → terrain
Argument type — a Brigadier ArgumentType that parses one argument off
the command line, however many words that takes — three for a Vec3Argument,
all of the rest for a MessageArgument; vanilla’s live in
net/minecraft/commands/arguments and are described to the client through an
ArgumentTypeInfo. → Brigadier and commands
Atlas — one large texture stitched at load time out of many sprite files, so a chunk section’s quads can all be drawn with one bound colour texture. → models and atlases
Attribute — a named, ranged, modifiable number on a LivingEntity;
modifiers are keyed by Identifier, and eight attributes are not
client-syncable at all. → attributes
Authority — whose copy of an entity produces the position that counts:
the server for a mob, the owning client for its own player and for the boat
that player is steering — the server still runs your player’s physics, then
overwrites its answer with the number your client sent. Five predicates on
Entity decide it, starting with the final
Entity.isLocalInstanceAuthoritative. → authority
Avatar — the class between LivingEntity and Player, holding the
player-shaped body and nothing else; a Mannequin is an Avatar that is not
a player. → player anatomy
B
Batch (game tests) — a group of game tests that share an environment and a dimension, and the key they are grouped by is that pair rather than a name. The renderer’s batch is a different word: quads grouped by render state, which belongs to section meshing and entity rendering. → game tests
Beardifier — the density-function term that bends terrain around a structure; terrain adaptation writes no blocks, it changes the noise. → structure placement
Behaviour — one unit of brain AI, gated on memories and asked once a
tick; unless it overrides Behavior.canStillUse it stops inside the same
Brain.tick that started it, so everything it does it does in
Behavior.start, or, for a one-shot, in its trigger. → AI
Biome — a named bundle of generation settings, block tints and environment attributes, attached to a 4×4×4 volume of the world; its mob spawns are one of those attributes. → biomes
BiomeSource — the object that answers “which biome is at this quart position”: by a climate search, from one fixed biome, from a checkerboard of a listed few, or, in the End, from the central island’s disc and a single erosion sample beyond it; all but the fixed and checkerboard sources make a separate BiomeResolver to answer. → biomes
Blaze3D — Mojang’s graphics layer: com/mojang/blaze3d keeps
RenderSystem, the window and the frame graph, and the GPU abstraction under
it is com/mojang/renderpearl, with OpenGL and Vulkan backends behind one
GpuDevice. → Blaze3D
Blend alpha — the mixing weight Blender computes from the distance to
the nearest measured old column: zero against the seam, where the old measurement sets the offset and fixed values the factor and jaggedness, and one out of range (use the noise). → blending at the old-chunk border
BlendingData — the sixteen-column ring of heights, densities and biomes
one of these measures out of its own chunk’s blocks, on the sides
facing ground the game has yet to generate; its presence on a chunk is what
makes the chunk old. → blending at the old-chunk border
Block — the singleton describing a kind of block: its behaviour, its
property set and its state table. One Block, many BlockStates. → blocks and states
Block entity — the per-position mutable state a block cannot fit into its state (a chest’s contents, a furnace’s progress), stored on the chunk. → block entities
Block event — a message from a block to itself (a piston push, a note
block, a chest lid), queued on ServerLevel and drained at one named phase of
the level tick — a wait for a phase rather than a delay, so it usually runs in
the very tick it was raised — and mirrored to nearby clients as a packet.
→ pistons and block events
BlockState — one combination of a block’s property values, built once by
the block’s StateDefinition and compared by identity;
Block.BLOCK_STATE_REGISTRY is the flat table that numbers it for the wire and
the global palette. → blocks and states
Border tick — a position along an old chunk’s seam queued for post-processing, so a leaf or a fluid there is re-evaluated when the chunk goes live. → blending at the old-chunk border
Boss bar — a ServerBossEvent holding a name, a progress float, a colour, an overlay style and three client effects (darkened screen, boss music, world fog), broadcast to the players attached to it; the
/bossbar kind adds an integer value and maximum, persists its membership as
UUIDs, and is execute store’s third sink. → scores, teams and stored data
Brain — the memory-and-behaviour AI that twenty mob classes build, villagers, piglins and axolotls among them, as opposed to the older goal system. → AI
Brigadier — Mojang’s command-parsing library: a tree of literal and argument nodes with per-node requirements, shared by client and server. → Brigadier and commands
Built-in block model — a block model attached in code by
BuiltInBlockModels rather than by a resource pack, living in a second
model table that terrain never reads; how a minecart or a block display
draws a chest. → block-entity rendering
C
Camera — the client’s eye: position, rotation and the cull frustum,
copied into a CameraRenderState once per frame — plus an
EnvironmentAttributeProbe that Camera.tick advances and no render state
carries. → the frame
Carver — a worldgen pass that hollows out caves and ravines by marking
their shape in a mask and writing no block itself; what each marked block
becomes — air, water or lava — is the Aquifer’s answer, in the Nether as
everywhere else. → terrain
Cell — the lattice unit of terrain noise, four blocks wide and deep and eight tall in the overworld, 768 to a chunk: the expensive three-dimensional density terms are evaluated at its corners and interpolated within it. → terrain, density functions
Chunk — a 16-by-16 column of the world’s full height: sections, heightmaps, block entities, tick queues and a status. → chunk anatomy
Chunk layer — which of the three ChunkSectionLayer buffers a block’s
quads are meshed into — solid, cutout or translucent — decided at bake time
from the alpha inside that quad’s own patch of its sprite, unless its material forces translucency, rather than from the block; the mesher only reads the answer, except for leaves, which it
overrides. → models and
atlases
ChunkHolder — the server’s per-chunk record of the level the loading graph computed for it, a future per threshold and status, and what changed in it this
tick; which tickets asked for that level is TicketStorage’s business, not the
holder’s. → tickets and loading
ChunkMap — the server’s chunk table: holders, the ticket-driven level graphs, entity tracking, and the region-file storage underneath. → tickets and loading
ChunkStatus — one rung of the generation ladder; a chunk advances one
status at a time, and the ChunkStep for each status declares the neighbour
radius that step needs. → the chunk generation pipeline
Climate — the noise sample (temperature, humidity, continentalness, erosion, depth, weirdness) a biome is chosen by. → biomes
Codec — a DataFixerUpper object that both encodes and decodes one type
against any DynamicOps; the corpus’s universal serialisation vocabulary. → codecs, NBT and JSON
CommandSourceStack — who is running this command, and from where:
position, rotation, level, entity, a PermissionSet and an output sink,
immutable, so a with… that changes something returns a copy. → Brigadier and commands
Compiled query — the immutable EntitySelector a parse produces:
thirteen fields and no grammar, resolvable any number of times
against different sources. → entity selectors
Component — two different things the corpus keeps apart: a data
component on an item stack, and a Component of chat text. → data components, text components
Connection — the tail handler of one Netty pipeline plus the channel it holds, with exactly one packet listener at a time, swapped when the protocol phase changes. → the connection
Container — the interface a thing with item slots implements (a chest, a hopper, an inventory), as distinct from the menu a player interacts with it through. → containers and menus
Criterion — two different things the corpus keeps apart: one condition
inside an advancement, backed by a CriterionTrigger the server fires when the
relevant thing happens; and a scoreboard objective’s ObjectiveCriteria, which
is what the objective counts. → advancements, scores, teams and stored data
D
DamageSource — the what hit you, and who is responsible record every damage calculation and death message reads. → damage and death
Data component — a typed, codec-backed value keyed by a
DataComponentType: a patch over the item’s prototype on a stack, a whole map on a block entity, a view over its own fields and its type’s prototype on an entity; what NBT item tags became. → data components
Data-driven type — the book’s name for the shape a dozen systems share: a
registry of types, each type a codec, and an instance a Codec produced from
JSON, dispatched on its own type field. Fifty-six registries follow it.
→ the data-driven type pattern
Data pack — a pack of JSON, structure NBT and function files supplying the server’s data-driven content; the server half of the resource system. → the resource system
DataLayer — the nibble array one section’s block light or sky light lives in, owned by the light engine and never by the section. → lighting
Debug subscription — a registered kind of debug value a client can ask the server for; half the kinds the server polls, diffs and sends only when they change, and the rest it pushes as they happen. → debugging the running game
DeltaTracker — the client’s clock: how much of a tick has elapsed, and the source of every partial tick in the frame but the lightmap’s and a frozen camera entity’s, each a literal one. → the client loop
Density function — a node in the JSON-defined graph that turns a
position into a number, but only once compiled into a DensitySampler: the
graph in the registry is never the graph that actually runs. → density functions
Dialog — a data-pack-defined form the server can put on a player’s screen, whose submitted values come back as a packet. → dialogs
Dimension — one ServerLevel and its DimensionType: a height range,
a set of environment attributes and its own chunk storage. → level data and rules
E
Enchantment — a data-pack record of effect components, most of them conditioned on loot predicates; its registry is synchronised, but a client that already has the pack is sent only the id. → enchantments
Entity — a thing the level ticks in its own right: a position, a bounding box, synched data and a tick method. A block entity is not one. → entity anatomy
EntityType — the registry entry for a kind of entity: its factory,
category, size, feature flags and the two numbers that decide how it reaches
clients. Spawn rules are keyed by the type in SpawnPlacements, not held on
it. → entity anatomy
EnvironmentAttribute — a per-dimension, per-biome, per-time-of-day, per-weather value resolved through a stack of layers: directly on the server, through the camera’s smoothing probe on the client. Not only the visual ones: alongside fog and sky colour sit twenty-three gameplay attributes — whether lava flows fast, whether piglins zombify, whether a bed works, and the villager’s schedule. → environment attributes and timelines
Event loop — the queue-and-thread pairing BlockableEventLoop is: an
owning thread that drains posted tasks and, through
BlockableEventLoop.managedBlock, keeps draining while it waits.
Minecraft and MinecraftServer are both one — but only the server rations
the drain against a time budget; the client empties the queue every frame. → the server tick
Experiment — a built-in data pack whose PackSource is
PackSource.FEATURE, enabling one non-vanilla FeatureFlag; switching one
on in the create-world screen is a data-pack reload. → creating a world
Extract — the first half of the client’s frame: walk the game state, cull
it, and write the render states, so that the drawing half reads no live game state from LevelRenderer.render down but what a few snapshots hold by reference (a moving block’s light, a sign’s text, a section compiled on the spot) — the top of the render half still does. The top-level states are single objects re-filled each frame, not fresh
immutable values. → the frame
F
Feature — the algorithm half of decoration: what to build, with no say in which positions it is offered. → features and placement
Feature flag — a bit in a FeatureFlagSet, which is a 64-bit mask and the
FeatureFlagUniverse it belongs to. There is one universe and four flags in
it: vanilla and three experiments. A registry element that is a
FeatureElement carries a set, and a lookup narrowed by the world’s enabled
set hides it — the registry is still frozen, and the view is what changed.
→ identifiers and
registries
Flat level generator preset — a display item plus a
FlatLevelGeneratorSettings, one row of the Superflat Presets screen; nine
ship. → creating a world
Fluid — the registry object behind a FluidState, a source and a flowing
instance per liquid, with FlowingFluid holding the spread algorithm and
LiquidBlock the block form. → fluids
Font — a resource-pack-defined glyph source plus the measuring and wrapping API on top of it; a glyph is baked into a texture the first time its font set is asked for it, and again after a reload or a font-option toggle. → text and fonts
Frame — two different things the corpus keeps apart. In Part XI it is one
pass of the client’s render loop, the unit partial tick, extract and the
frame graph are all relative to (the
frame).
In Part XIII it is the execution engine’s unit of a running function: a depth, a
result callback that /return feeds sideways, and a control that can delete
the frame’s pending work — one object shared by reference across a whole
function body, and deliberately not a stack frame. → the execution engine
Frame graph — the client’s per-frame declaration of render passes and the targets each reads and writes, resolved before anything is drawn. → visibility and the frame graph
Function — a .mcfunction file: a list of commands loaded as a
CommandFunction, optionally with macro lines. → functions and macros
G
Game event — a broadcast fact about something that just happened at a position (a block placed, a door opened) that sculk sensors and mobs listen for. → game events and vibrations
Game rule — one typed, server-wide switch or number in GameRules, saved
with the world and sometimes sent to the client. → level data and rules
Game test — a data-driven test instance: a structure, an environment and a check the server runs and reports on. → game tests
Globally-rendered block entity — one whose renderer says
BlockEntityRenderer.shouldRenderOffScreen, so the client keeps it in a
level-wide set and draws it whether or not its section is visible; three
renderers qualify. → block-entity rendering
Goal — one unit of the older mob AI: a start condition, an answer to
whether it may be interrupted, and the set of Goal.Flag controls it claims
while running. The priority belongs to the WrappedGoal that holds it, and
the flag table rather than the priority is what arbitrates. → AI
GpuDevice — the façade every GPU resource is created through; both
graphics backends sit behind it as GpuDeviceBackend implementations. A draw
reaches the driver through the CommandEncoder it hands out and the
RenderPass that opens. → Blaze3D
GuiRenderState — the 2D render tree: strata of nodes that infer their own layering from bounding boxes and are batched into draw calls at the end of the frame. → the GUI render tree
H
Heightmap — a per-chunk 2D array holding the first free Y above the topmost block matching a predicate; six types exist, and a live chunk keeps the four that survive worldgen and any asked for since. → chunk anatomy
Holder — a reference to a registry entry that can exist before the
entry is bound: Holder.Reference for a registered value, Holder.Direct
for an inline one. → identifiers and registries
HolderSet — a set of holders: either a tag (HolderSet.Named) or a
literal list. → tags
HUD — the in-world overlay (hotbar, hearts, chat, boss bars), which is
the class Hud; Gui is the screen and overlay manager that holds it. → the HUD
I
Identifier — a namespace and a path; the id of everything. A 1.21-era reader knows it as ResourceLocation. → identifiers and registries
Ingredient — a recipe’s “any of these items” test. It cannot be an empty inline list, but a tag that resolves to nothing makes one empty — and a recipe holding it is never placeable. → recipes
Integrated server — the MinecraftServer a singleplayer client runs on
its own Server thread. Every change to the world still crosses as a packet;
a handful of settings cross by direct call. → anatomy
InteractionResult — the answer a block or item gives to a click: was the input consumed, should the arm swing, did the held item change. → block interaction
Item — the singleton for a kind of item, holding none of the components a stack shows; a stack is a holder to one of these, a count, a pop time and a patched component map — the item’s defaults plus the ways this stack differs from them. → items and stacks
ItemStack — the object in a slot: a final class holding a count, a pop
time, a Holder of Item, and a patch of data components over the item’s
prototype. Only the last is really data; equality is the prototype plus the
patch, which is why two identical-looking stacks may refuse to merge.
→ items and stacks
ItemStackTemplate — the immutable item-shaped record (an item holder, a
count, a component patch) that data uses where a live, mutable ItemStack
would be wrong. → items and stacks
J
Jigsaw — the structure-assembly system that grows a village out of template pieces by matching connector blocks. → jigsaw and templates
K
KeyMapping — one bindable action: whether its key is down, plus a counter
of owed clicks that KeyMapping.consumeClick drains rather than
edge-detects. → input and keybinds
L
Level — a world: ServerLevel on the server, ClientLevel on the
client, sharing an abstract Level that carries most of the behaviour —
ClientLevel differs by hollowing out inherited methods rather than by
implementing fewer of them. The word is also a number elsewhere: a ticket level is how strongly a chunk is held, a permission level is one of five ranks, and an enchantment level is an enchantment’s strength. → the level tick, the client level
Lightmap — the small texture the client samples to turn a block-light / sky-light pair into a colour; drawn on the GPU once per tick. → lightmap, fog and sky
LivingEntity — the rung of the entity tree that can be hurt and healed:
health, attributes, mob effects, equipment and the shared movement code. It
holds 124 of Entity’s 192 descendants and has exactly three direct
subclasses — Avatar, ArmorStand and Mob.
→ entity
anatomy
LocalPlayer — the Player a human steers: its own ClientInput, and the
last input and position it sent. The block predictions it seems to own are
the level’s. → player anatomy
Loot condition — a predicate in the loot package’s own vocabulary
(LootItemCondition), asked with a loot context; the same objects the
/execute if predicate check runs, under a different name. → contexts and predicates
Loot context — the bag of what was involved — the level, the position,
the tool, the killer, the block entity — that every loot roll, predicate and conditional enchantment effect is asked
against; a LootContext built to satisfy exactly
one parameter set. → contexts and predicates
Loot table — the data-driven roll that turns an event (a block broken, a mob killed, anything at all reading a container that has not been rolled yet) into item stacks. → loot tables
M
Macro — a function line beginning with a $ substitution. The plain
lines of the file are parsed once at load; a macro line is substituted and
re-parsed per distinct argument tuple, cached only eight deep. → functions and macros
Memory — one typed, optionally expiring value in a Brain; behaviours are gated on which memories are present, absent or merely registered. → AI
Menu — the object behind an open container screen, one copy on each side: slots and a state id, and on the server’s copy the synchroniser that sends its changes to the client. → containers and menus
MinecraftServer — the object that is the server: the thread, the tick
loop, every ServerLevel, the player list and the registries the world was
loaded with. Singleplayer runs one too, as an IntegratedServer inside the
client’s process.
→ the server tick
MultiPlayerGameMode — the client’s channel for acting on the world: every break, place, use and attack goes through it, and the ones that predict open a prediction window before they send. → prediction and acknowledgement
N
NBT — Minecraft’s binary tag format: a sealed Tag hierarchy
whose scalars are records and whose containers (CompoundTag, ListTag) are
final classes, read and written through NbtIo and reached by codecs through
NbtOps. → codecs, NBT and JSON
NBT path — a compiled query over a tag, six node kinds deep — a named
child, an index, all elements, and three kinds of match — that /data uses to
read and write, and which materialises the compounds and lists it walks through on a write, though an index never creates the element it names. → scores, teams and stored data
Neighbour update — the server-only notification a block sends its six neighbours after a change; distinct from a shape update, which runs on both sides. → blocks and states
NoiseChunk — the per-chunk workspace (a height query builds a one-column one of its own): it binds the dimension’s compiled
samplers to one context that grants the caches the density-function graph
asked for and carries the chunk’s beardifier and blender, and it holds the
chunk’s Aquifer. → density
functions
NoiseRouter — the eight density functions a generator asks for, as one
record: six are the climate, which NoiseRouter.createClimateSampler turns
into a Climate.Sampler; chunk_surface_level is the surface pass’s; and
final_density is the one the fill samples. → density functions
O
Objective — a named scoreboard column: a criterion, a display name, a
render type and a number format. Only the dummy and trigger criteria wait
for commands; every other one — including every statistic in the game, since
Stat extends ObjectiveCriteria — is driven from ServerPlayer. → scores, teams and stored data
Occlusion — four unrelated questions the corpus asks with one word, and
no page owns all four. Does this section hide that one is the visibility
graph’s, and the answer is a walk rather than a test (visibility and the frame
graph).
Does this face stop light, or hide the face against it is a shape question
every BlockState precomputes — a field on the state itself rather than
anything in its shape cache (math and
primitives) — and it is, beside a block’s light dampening, what ends a sky column (lighting); the fluid wall test asks the same question of two blocks’ collision shapes, to decide whether fluid may pass between them (why the wall test is affordable). Ambient
occlusion is none of that: it is the per-quad shading the mesher bakes in
(section meshing). And a vibration’s
occlusion is a six-ray raycast for BlockTags.OCCLUDES_VIBRATION_SIGNALS that
reports occluded only if all six are stopped (game events and
vibrations).
A page always means exactly one of the four; the word is the only thing they
share.
Old chunk — a chunk whose ChunkAccess.blendingData is non-null, which
is to say one whose save data carried a blending_data compound;
ChunkAccess.isOldNoiseGeneration is the test. → blending at the old-chunk border
P
Packet — an interface: a PacketType, which is a name and a direction,
and one handler method. It comes in three shapes, each class but the bundle packet and its delimiter declares its wire form as a StreamCodec, which the phase’s protocol description dispatches to by the packet’s type, and two dozen types are registered into more than one phase. → packets and stream codecs
PalettedContainer — the bit-packed storage a chunk section keeps its block states and biomes in, with a palette that grows as the section gets more varied. → chunk anatomy
Parameter set — the declared contract between a loot context and the
things asked against it: a ContextKeySet naming which ContextKeys must be
present and which may be. Building a context with a parameter the set does
not allow, or without one it requires, throws; asking for one that is absent gives nothing; and loading a table reports an element that reads a key its set does not allow — the three ways a parameter can be missing. All thirty-one
sets are listed in loot context parameter sets. → contexts and predicates
Partial tick — the fraction of a tick elapsed at the moment a frame is drawn, used to interpolate the world. There is no single one: a frame carries six values, they disagree on purpose, and two of them, the one screens are handed among them, are not fractions of a tick at all. → the frame
Particle — a client-only object with a position, a velocity and an age,
most of them a textured quad, ticked by the ParticleEngine and extracted from
it each frame. Nothing about it is authoritative: it is spawned locally, by
prediction, by the ambient scatter or on a packet or level event, and the
server is told the player’s particle setting and never acts on it.
→ particles
Path — the list of nodes a navigator is following, produced by
PathFinder’s A* over a snapshot of already-loaded chunks, with a
NodeEvaluator deciding what each candidate block is to this mob. → pathfinding
Permission atom — a named capability with an Identifier
(Permission.Atom), the other kind of permission besides a command level;
an operator’s level-based set grants exactly one, the entity-selector atom,
from gamemaster up. → permissions
Permission level — one rung of PermissionLevel (all, moderators,
gamemasters, admins, owners), and only the ordered half of a permission: a
command source carries a PermissionSet and a node requires a
PermissionCheck, neither of which is an integer. → permissions
Permission set — what a command source carries and a node’s check is
asked against: on the server a level-based set (a rung plus one atom). The
client rebuilds four of those from the op level it is told — rung zero maps to
PermissionSet.NO_PERMISSIONS instead — and keeps a chat set built by
subtraction beside them. No packet carries a PermissionSet itself. → permissions
PlacedFeature — a feature plus an ordered list of placement modifiers; the unit a biome names. → features and placement
Player — the shared player class: the inventory and ender chest, the open
menu, the abilities, the food and experience bars, sleep, and the cooldowns.
ServerPlayer is its server subclass, LocalPlayer and RemotePlayer its client ones (through AbstractClientPlayer), and an Avatar is the rung above it.
→ player anatomy
Point of interest — a block state the game has decided is worth walking
to: a bed, a job site, a portal. PoiManager indexes them by position, in its
own files beside the chunks. → points of interest
Prediction ledger — the corpus’s name for BlockStatePredictionHandler:
the client’s record of what the server is known to have at a block it changed
optimistically. The ack is a receipt for a number, not a verdict — it settles
every entry at or below it, writing back a correction if one arrived and
rolling the block back if none did. → prediction and acknowledgement
Protocol phase — one of handshaking, status, login, configuration and play; each has its own packet table and its own listener. → protocol phases
Q
Quart — a four-block cell, the resolution biomes are stored and
sampled at; QuartPos is the arithmetic, tabulated with the other coordinate spaces in math and primitives.
→ biomes
R
Random tick — a turn a block gets that nobody booked: aimed at no block, carrying no promise, and drawn from the entity-ticking chunk set rather than the wider block-ticking one a scheduled tick uses — so there is a ring of chunks where appointments come due and nothing is ever chosen at random. → scheduled ticks
Recipe — a server-side matcher and assembler; no Recipe ever crosses
the wire. The client gets a RecipeDisplay and a RecipeDisplayId, which is a
position in a list rather than the recipe’s name. → recipes
Region file — the 32-by-32-chunk container file chunks are stored in, addressed by a sector table at its head. → chunk storage
Registry — a frozen, id-assigning table of one kind of thing; some are built into the jar, some are loaded from data packs, some are sent to the client, and the 161 registry keys are listed in registries. → identifiers and registries
Reload listener — the unit of a reload: one object that reads what it
needs off the worker pool and swaps its live state on the owning thread, every
apply running in order behind a PreparableReloadListener.PreparationBarrier. → the resource system
Render state — the snapshot of what to draw, produced by the extract half
of the frame and consumed by the drawing half; the property that matters is that the drawing half reads no game state below LevelRenderer.render but what a few snapshots hold by reference (see
Extract for where the wall actually is).
→ the frame
RenderPipeline — the client’s declaration of how to rasterise: shaders,
blend, depth, cull, vertex format, topology. It says nothing about which
textures to bind, which RenderType adds, or which target to draw into,
which is the one its RenderPass was opened on. → Blaze3D
RenderType — a RenderPipeline plus the textures to bind and the
layering and batching rules. Neither says which target to draw into: a
RenderType draws into whatever RenderPass its caller has opened. → Blaze3D
Resource pack — a pack of assets; the client half of the same pack machinery data packs use. → the resource system
S
SavedData — a named, codec-backed blob stored beside the world (the
border, the weather, the rules, raids, the dragon fight); level.dat
itself is nearly a stub. → level data and rules
Scheduled tick — a block or fluid position queued to run at a named future tick, with a priority breaking ties inside that tick. → scheduled ticks
Score — one number for one holder under one objective, reached through a
ScoreAccess handle rather than a setter. → scores, teams and stored data
Screen — one full-window client UI with its own widget tree and lifecycle; the server is told nothing about most of them. → GUI and screens
Section — a 16-cubed piece of a chunk: one paletted container of block states, one of biomes, and four counters. Its light lives in the light engine’s own storage, not on the section. → chunk anatomy
Section mesh — what one section compiled to (CompiledSectionMesh): a draw record per layer into the shared vertex arenas, the visibility set, the block entities and the translucent layer’s sort state, rebuilt when the section is both dirty and visible —
usually on a worker, but inline on the Render thread when the chunk-builder
option asks for it. → section meshing
Selector head — the single character after the @ (a, e, n, p, r, s) that sets a selector’s default limit, order and entity scope before any option is read; three of the six also pin the type to player, and two instead add an aliveness test. → entity selectors
Sensor (brains) — the half of brain AI that writes memories from the world, on a fixed interval. A sculk sensor is a block and a different subject (one footstep, and the ticks it takes to arrive). → AI
ServerEntity — the server’s per-tracked-entity bookkeeping: what the watching clients were last told, and what to send them next. → what the client is told
ServerPlayer — the server’s copy of a player: the entity, the connection’s
listener, the game-mode object and the session state, all on one class.
LocalPlayer is the client’s answer to it, RemotePlayer the client’s copy of anyone else, and all three share Player.
→ player
anatomy
Shape update — the “your neighbour changed, recompute yourself” call that runs on both client and server, unlike a neighbour update. → blocks and states
Signed message — a chat message carrying a signature over its content and its place in a per-player chain, so the server, and every client that holds the sender’s session, can check who said it. → chat and signing
Simulation distance — how far the world ticks, as against how far you
can see: the radius behind TicketType.PLAYER_SIMULATION, deciding which
chunks tick blocks, fluids and entities. → tickets and loading
Special model renderer — a hand-written submitter for a shape no cuboid model can express, reached from an item model or a block state rather than from a block entity; thirteen of them. → block-entity rendering
Staging buffer — in the command engine, the list an executing action appends its spawned
commands to, spliced onto the head of the queue after it runs — which is
what makes an ArrayDeque behave as a call stack. → the execution engine
Status effect — a MobEffect held on a LivingEntity with an amplifier
and a duration; the ones that change a number mostly do it by attaching an
attribute modifier, and others are read where the number is used, jump boost, slow falling and levitation among them.
→ status effects
StreamCodec — the wire counterpart of a Codec: encodes to and decodes
from a ByteBuf, with no schema and no field names. → packets and stream codecs
Structure — a generated building or landmark: a placement lottery, a start assembled in memory, and pieces written a chunk at a time. → structure placement
StructurePiece — one room, corridor or slab of a structure. In the
hand-built half it is a Java class that writes its own blocks and, in the procedural
graphs, constructs its own neighbours, chosen by no pool; the jigsaw half’s PoolElementStructurePiece
is one too. Every piece carries a registered StructurePieceType, which is how
it comes back off disk. → hand-built
structures
StructureStart — one decided structure: the Structure, the chunk it
started in, a PiecesContainer, a reference count and a cached bounding box,
stored on the chunk it began in. → structure placement
Submit node — one thing to draw that is not terrain, written into
SubmitNodeStorage by the submit pass out of the render states extract left
behind, and sorted into a phase before the feature renderers turn it into
vertices; the fifteen phases and the twelve renderers are listed in
submit phases. → entity rendering
SynchedEntityData — the per-entity table of small values the server pushes to watching clients, keyed by class-tree ordinal. → synched entity data
T
Tag — a named set of registry entries defined by data packs and merged across them, unless a higher pack sets replace. (The unrelated NBT sense of the word belongs to NBT.) → tags
Team — a named set of score holders carrying a colour, a friendly-fire flag, a collision rule and a nametag rule — so a class in the scores package is read by collision and by rendering. → scores, teams and stored data
Tick — one step of the server’s simulation, 50 ms at the default rate
that /tick rate can change, or one step of the client’s; a client behind
the clock catches up to ten accumulated ticks in a frame and discards the
rest. → the server tick, the client loop
Ticket — the reason a chunk is loaded: a type and a level, fed by its type’s flags into one or both of two separate graphs, with ChunkLevel deciding what the level buys — a holder still generating toward some status, then full, then block-ticking, then entity-ticking. → tickets and loading
Timeline — one clock’s data-driven curve set: an optional period, the
named instants on that clock, and one AttributeTrack per environment
attribute — keyframed over modifier arguments, which for an override are the values themselves. → environment attributes and timelines
Trigger (advancements) — the server-side hook that tells one player’s
advancement state that something happened, by sweeping that player’s listener
map for this trigger. Nothing broadcasts. The scoreboard’s trigger criterion
and the /trigger command are a different thing entirely (scores, teams and
stored data).
→ advancements
U
Unattended command — a command the player did not type: a dialog button
or a chat click event, sent through ClientPacketListener.sendUnattendedCommand.
The client re-parses it and asks first if it fails to parse, needs a signature,
or needs any permission at all; a clean one goes without a
prompt. A sign’s command is not one of these — it runs on the server at
gamemaster level, only on a sign whose allow_op_features flag is set, and
the client is never asked. → permissions
V
View distance — how far the server sends chunks. A client’s render-distance request only clamps what it is sent; the ticket radius comes from the server’s own number. Not simulation distance, which is how far the world ticks. → tickets and loading
VoxelShape — the collision or outline volume of a block state, a set of boxes over a bit grid, with fast merge and sweep operations. → math and primitives
W
Watchdog — ServerWatchdog, the daemon that treats a tick longer than
max-tick-time as a dead server: it writes a crash report, calls System.exit,
and halts the JVM ten seconds later if the shutdown has not ended it by then. → how a server dies
Widget — an AbstractWidget, the reusable piece of a screen: a button, a
slider, an edit box. It is usually in all three of a screen’s lists at once —
the children that get input, the renderables that get extracted, and the
narratables that get described.
→ GUI and screens
Window — the SDL window handle the whole client hangs off: the
framebuffer size, the GUI scale, fullscreen, and the nineteen kinds of event
Window.handleEvent answers — not the input ones, which SDLEventHandler
hands to KeyboardHandler and MouseHandler. → the window
World clock — the identity a timeline is sampled against: a unit record
in Registries.WORLD_CLOCK, two of them in vanilla, holding nothing at all.
The tick count, the rate and the paused flag are
ServerClockManager.ServerClockInstance’s, one per clock, and /time can move or
pause each independently. → environment attributes and timelines
World gen settings — WorldGenSettings: a WorldOptions (the seed,
generate structures, bonus chest and a legacy string) and the LevelStem map, a SavedData
written to data/minecraft/world_gen_settings.dat. It is the one piece of world generation’s own settings that is saved; the worldgen registries are re-read from the enabled packs on every world open, and a template is looked for in the world’s own files before the packs’. → creating a world
World-limited — a parse-time flag, set by any of seven positional selector options, that confines a selector’s resolve to the source’s own level instead of every level on the server. → entity selectors
World preset — a WorldPreset registry entry holding one LevelStem
per dimension; what the world-type button selects, and what level-type
names on a dedicated server. → creating a world
World stem — WorldStem, the four things WorldLoader.load hands the
server constructor in one bundle: the resource manager, the reloadable server
resources, the layered registries, and the level data with its gen settings. → starting a server
WorldGenRegion — the bounded, write-guarded view of the world a generation step is given; it throws rather than loading a chunk, which is why cascading worldgen cannot happen. → the chunk generation pipeline
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.
Diagram lanes
Generated from
TEMPLATE.md’s lane key bypython tools/check_lanes.py --index. Do not edit by hand.
Almost every lane in a sequence diagram is a class, and a lane means the same thing on
every page: the key in TEMPLATE.md is the authority, and check_lanes.py fails a deploy on
a page that disagrees with it. The last rows are the exceptions — lanes that stand for a
thread, the process, the wire, the disk, a service or the game’s own code rather than for
one class.
273 lanes are classes and 10 are not. A lane is normally the initials of
the class’s CamelCase words, a trailing Impl dropped (ServerGamePacketListenerImpl is SGPL),
but more than a third are not: a short one-word class is its own lane (Player,
Sheep), a longer one-word class
takes a fixed prefix (Connection is Conn, Enchantment is Ench), and a collision is
resolved by lengthening the later claimant, never by reassigning a row — which is why
ChestMenu is ChestM and not CM (ChunkMap had it first); and a class a page draws twice, as two
copies, has a second lane; and a few follow none of these (MC is Minecraft, PDM is PatchedDataComponentMap). Derive nothing from a lane;
read it off this table.
| lane | class |
|---|---|
AA | AbstractArrow |
AB | AbstractBoat |
ACM | AbstractContainerMenu |
AFBE | AbstractFurnaceBlockEntity |
AP | AcquirePoi |
ATS | AttributeTrackSampler |
AttrI | AttributeInstance |
AttrM | AttributeMap |
BC | BuildContexts |
BD | BlendingData |
BDFS | BlendDensityFunction.Sampler |
BDR | BrainDebugRenderer |
BERD | BlockEntityRenderDispatcher |
BI | BucketItem |
BIR | BuiltInRegistries |
BLE | BlockLightEngine |
Blender | Blender |
Block | Block |
Boot | Bootstrap |
Brain | Brain |
BSPH | BlockStatePredictionHandler |
CA | ChunkAccess |
CAdv | ClientAdvancements |
Camera | Camera |
CBE | ChestBlockEntity |
CCC | ClientChunkCache |
CComPL | ClientCommonPacketListenerImpl |
CCPL | ClientConfigurationPacketListenerImpl |
CDS | ClientDebugSubscriber |
CE | CommandEncoder |
CEM | EnchantmentMenu |
CGT | ChunkGenerationTask |
CH | ChunkHolder |
ChanA | ChannelAccess |
ChatC | ChatComponent |
CHelp | ContainerHelper |
ChestM | ChestMenu |
ChestR | ChestRenderer |
CHPL | ClientHandshakePacketListenerImpl |
ChunkG | ChunkGenerator |
CL | ClientLevel |
ClimS | Climate.Sampler |
CLis | ChatListener |
CM | ChunkMap |
CMap | ContextMap |
Cmds | Commands |
CMTE | ChunkMap.TrackedEntity |
CNU | CollectingNeighborUpdater |
Conn | Connection |
Cons | Consumable |
CPL | ClientPacketListener |
CPList | Climate.ParameterList |
CraftM | CraftingMenu |
CRU | ComponentRenderUtils |
CScr | ChatScreen |
CSED | SynchedEntityData |
CSP | ClientSuggestionProvider |
CST | ChunkStatusTasks |
CSug | CommandSuggestions |
CT | CombatTracker |
CTD | ChunkTaskDispatcher |
CWS | CreateWorldScreen |
DataC | DataCommands |
DB | DoorBlock |
DCP | DataComponentPatch |
DCS | DialogControlSet |
DlgC | DialogCommand |
DlgS | DialogScreen |
DM | DistanceManager |
DMR | DefaultedMappedRegistry |
DRWE | DefaultRedstoneWireEvaluator |
DS | DedicatedServer |
DScr | DeathScreen |
EAP | EnvironmentAttributeProbe |
EAS | EnvironmentAttributeSystem |
EffC | EffectCommands |
EH | EnchantmentHelper |
EM | EnchantmentMenu |
Ench | Enchantment |
Entity | Entity |
ERD | EntityRenderDispatcher |
EScr | EnchantmentScreen |
ET | EntityType |
ETL | EntityTickList |
EVS | EnvironmentAttributeSystem.ValueSampler |
ExecC | ExecuteCommand |
FBR | FormattedBidiReorder |
FD | FoodData |
FeatP | FeaturePlacer |
Feature | Feature |
FF | FlowingFluid |
FGB | FrameGraphBuilder |
FM | FurnaceMenu |
FolP | FoliagePlacer |
Font | Font |
FP | FoodProperties |
FPHAI | FirstPersonHandsAndItems |
FPHAIR | FirstPersonHandsAndItemsRenderer |
FR | FogRenderer |
FRD | FeatureRenderDispatcher |
FSet | FontSet |
GC | GiveCommand |
GD | GpuDevice |
GGE | GuiGraphicsExtractor |
GI | GameTestInfo |
GlCE | GlCommandEncoder |
GlRP | GlRenderPass |
GpuS | GpuSurface |
GR | GameRenderer |
GS | GaussianSampler |
GStit | GlyphStitcher |
GTR | GameTestRunner |
GTT | GameTestTicker |
Gui | Gui |
GuiR | GuiRenderer |
Hud | Hud |
ICT | InventoryChangeTrigger |
Ignite | Ignite |
InvS | InventoryScreen |
IOW | IOWorker |
IP | ItemParser |
IS | IntegratedServer |
IStack | ItemStack |
Item | Item |
Items | Items |
JP | JigsawPlacement |
JPP | JigsawPlacement.Placer |
JS | JigsawStructure |
KH | KeyboardHandler |
KI | KeyboardInput |
KM | KeyMapping |
KTS | KeyframeTrackSampler |
Language | Language |
LB | LiquidBlock |
LC | LevelChunk |
LCS | LevelChunkSection |
LCTs | LevelChunkTicks |
LDS | LevelDebugSynchronizers |
LE | LivingEntity |
LevB | LeverBlock |
Library | Library |
LIC | LootItemCondition |
LIF | LootItemFunctions |
LLE | LevelLightEngine |
LLSS | LayerLightSectionStorage |
LM | Lightmap |
LO | LoadingOverlay |
LootC | LootContext |
LootP | LootParams |
LP | LocalPlayer |
LR | LevelRenderer |
LRSE | LightmapRenderStateExtractor |
LSA | LevelStorageSource.LevelStorageAccess |
LT | LootTable |
LTs | LevelTicks |
LX | LevelExtractor |
MC | Minecraft |
ME | MobEffect |
MEI | MobEffectInstance |
MFR | ModelFeatureRenderer |
MNBS | MultiNoiseBiomeSource |
Mob | Mob |
MoveC | MoveControl |
MPGM | MultiPlayerGameMode |
MR | MappedRegistry |
MS | MinecraftServer |
MTS | MoveToTargetSink |
NBC | NoiseBasedChunkGenerator |
NbtIo | NbtIo |
NC | NoiseChunk |
NE | NodeEvaluator |
PA | PlayerAdvancements |
Parrot | Parrot |
PBB | PistonBaseBlock |
PChain | PostChain |
PCS | PlayerChunkSender |
PDec | PacketDecoder |
PDM | PatchedDataComponentMap |
PDS | PlayerDataStorage |
PEnc | PacketEncoder |
PESM | PersistentEntitySectionManager |
PESP | PoolElementStructurePiece |
PF | PathFinder |
PL | PlayerList |
Player | Player |
PM | PoiManager |
PMBE | PistonMovingBlockEntity |
PMod | PlacementModifier |
PN | PathNavigation |
PNR | PathNavigationRegion |
PP | PacketProcessor |
PPass | PostPass |
PSR | PistonStructureResolver |
PST | PrepareSpawnTask |
RB | RepeaterBlock |
RCPL | ClientPacketListener |
RDC | RegistryDataCollector |
RemS | RemoteSlot |
ResultC | ResultContainer |
ResultS | ResultSlot |
RGL | ReportGameListener |
RM | RecipeManager |
RootP | RootPlacer |
RP | RenderPass |
RRM | ReloadableResourceManager |
RSR | ReloadableServerResources |
RSReg | ReloadableServerRegistries |
RWB | RedstoneWireBlock |
SA | ScoreAccess |
SAB | AbstractBoat |
SAI | SpatialAttributeInterpolator |
SBL | SoundBufferLibrary |
SCC | ServerChunkCache |
SCD | SerializableChunkData |
SCL | ServerConnectionListener |
SCPL | ServerConfigurationPacketListenerImpl |
Screen | Screen |
SDS | ServerDebugSubscribers |
SE | ServerEntity |
SectC | SectionCompiler |
SED | SynchedEntityData |
SEE | SoundEngineExecutor |
SGPL | ServerGamePacketListenerImpl |
ShadM | ShaderManager |
Sheep | Sheep |
SIB | SleepInBed |
SICF | SetItemCountFunction |
SL | ServerLevel |
SLPL | ServerLoginPacketListenerImpl |
SndE | SoundEngine |
SNS | SubmitNodeStorage |
SP | ServerPlayer |
SPB | StructurePiecesBuilder |
SPGM | ServerPlayerGameMode |
SPie | StrongholdPieces |
SR | SkyRenderer |
SRB | ServerRecipeBook |
SRD | SectionRenderDispatcher |
SRI | SimpleReloadInstance |
SRT | SynchronizeRegistriesTask |
SS | ServerScoreboard |
SSB | SculkSensorBlock |
SSpl | StringSplitter |
SStr | StrongholdStructure |
SSVU | SculkSensorBlockEntity.VibrationUser |
STemp | StructureTemplate |
SumC | SummonCommand |
SUT | SectionUpdateTracker |
SW | ServerWatchdog |
TagP | TagParser |
TC | TestCommand |
TCC | TransientCraftingContainer |
TDec | TreeDecorator |
TDSS | TrackingDebugSynchronizer.SourceSynchronizer |
TF | TreeFeature |
TIB | TestInstanceBlockEntity |
TL | TagLoader |
TLE | ThreadedLevelLightEngine |
TP | TrunkPlacer |
TrC | TranslatableContents |
TS | TicketStorage |
TVO | TagValueOutput |
UAFS | UpdateActivityFromSchedule |
VSel | VibrationSelector |
VSL | VibrationSystem.Listener |
VST | VibrationSystem.Ticker |
WCUS | WorldCreationUiState |
WGL | WorldGenLevel |
WL | WorldLoader |
WOF | WorldOpenFlows |
WR | WorldgenRandom |
ZM | ZombieModel |
ZR | ZombieRenderer |
Auth | the User Authenticator thread, not a class |
Disk | the save on disk, not a class |
Game | the game’s own code above Blaze3D, not a class |
Hook | the Server Shutdown Thread JVM hook, not a class |
JVM | the process itself, not a class |
Main | the thread running whichever program’s Main the diagram is about — the client’s on the JVM main thread, the server’s on the thread the jar’s bundler starts — so not one class |
Netty | the Netty event loop, not a class |
Sess | the Mojang session service both machines call over HTTPS, not a class |
Wire | the network between the two programs, not a class |
Worker | the Util.backgroundExecutor pool, not a class |
Class index
Generated by
python tools/verify_names.py --indexon every deploy. Do not edit by hand.
Every class backticked on a page — a system page, a Reference page, the atlas, the
introduction or the lecture map — or named inside one of its figures (a lane, a node, a
message; tools/check_figure_names.py reads those), and the pages that name it. Outside it:
the generated pages: the eleven views, whose rows are read off the decompile, and the lane index, read off the page template’s key.
3169 names across 132 pages. A row is a
simple name, not a class: a few names belong to more than one class (there are five
Mains), and a few are library classes from Brigadier, DataFixerUpper or authlib.
The lecture map
The video series is an ordering over the system pages, which are the stable content. Each part’s landing page lists its lectures in watching order and says in a line what each one is; this page assembles the thirteen orders and states the dependencies between them — where a later part is assumed by an earlier one, and what to watch first because of it. It was drafted one part per session, and the owner confirms it — reading each part with the decompile open — before anything is recorded. Nothing in Reference is watched, and nothing in the maps is: the maps are looked at once, the reference pages are looked up, and a lecture links into them wherever a viewer would pause the video to read a table.
Because the subject here is the order, nothing below describes a lecture. What each one is about is said once, on its part’s landing page; what is here is what the order needs — how each part is ordered inside itself, the lectures that must be watched together or in a fixed sequence, and the pages worth taking out of turn. There is exactly one departure from the sidebar order, environment attributes and timelines before Part III’s second lecture; and there are three pages a viewer coming for one part alone has to fetch from elsewhere in the book, which the end of this page names.
The one rule that holds everywhere: each part assumes only the parts before it, and where that is not true the section says so and names the page to watch early. The dependencies between parts, at the end, draws the whole graph.
I · Anatomy
- Anatomy — watched first, because every later diagram’s lanes assume it.
- What this book skips — second and not last, because a boundary is worth drawing before you invest in thirteen parts rather than after.
II · Foundations
Watch 1 and 2 in that order and before everything else in the part. The five below them lean on those two far more than on each other, so 3 to 7 have little order among themselves: tags come after the resource system, whose stack they are read from, and 7 is the pattern the other six exist to make possible and reads best last.
- Codecs, NBT and JSON
- Identifiers and registries
- The resource system
- Tags
- Data components
- Text components
- The data-driven type pattern
III · The server
Part III is a line into a loop and out again: the loop first, because it is what the rest of the book lives inside, and the beginning and the end last, because they are only interesting once you know what they start and stop.
- The server tick
- The level tick — watched immediately after the first, and never apart from it.
- Players and sessions
- Starting a server
- How a server dies
Watch environment attributes and timelines (Part IV) before the level tick if you want its opening to mean anything. It costs nothing to take out of order — it depends on nothing but registries and codecs — and Part III is the earliest of the six other parts that lean on it.
IV · The world
Lectures 2 to 6 are the vocabulary page and the conveyor after it, and must be watched in that order — nothing later in the chain can be watched first. Lecture 1 is off the line on purpose. Of the last four, 7 comes before 8; the other two need only the vocabulary page and can go anywhere after it.
- Environment attributes and timelines — first, for the reason Part III already gave: it depends on nothing but registries and codecs, and nine pages in six other parts cite it, though only four of those parts’ landing pages list it (the table at the foot).
- Chunk anatomy — the vocabulary page. Everything below assumes it, and so does Part V.
- Tickets and loading
- The chunk generation pipeline — Part XII is the cargo on this conveyor and cannot be watched before it.
- Lighting — inside the chain, but nothing later in this part needs it first (chunk storage points at it only for the layers an unload drops); one page each in Parts IX and X cites it, and neither part’s landing page lists it.
- Chunk storage
- Scheduled ticks — two of Part V’s redstone lectures assume this one, and so does the next.
- Fluids
- Game events and vibrations
- Points of interest — Part VI’s AI lecture reads this index, so watch this one first if you are taking that part next.
V · Blocks
Part V is a hub and six spokes, and the hub is watched first because the
other six all reach back into the same pair of figures in it: what
Level.setBlock and LevelChunk.setBlockState do with a write. Those
figures are also where the two update channels are defined, which is the
vocabulary every later lecture in the part uses. Two of the six are one
lecture in two halves.
- Blocks and states
- Block interaction
- Block breaking — the same lecture’s other half. Watch it immediately after, and never apart from it.
- Block entities — reaches forward into Parts VI, VII and XI, most of all for the menu a furnace’s progress travels in.
- Signal and dust — assumes scheduled ticks only lightly.
- Pistons and block events
- Diodes and the observer — assumes scheduled ticks properly: a repeater’s delay is an entry in that queue.
Part V’s two click lectures are the applications of prediction and acknowledgement in Part X, and that page’s own scenario needs this part’s vocabulary. The dependency is circular and this book cuts it here.
VI · Entities
Part VI is a ladder, and nothing in it can be reordered without breaking something.
- Entity anatomy
- Authority — short, and the rung the rest of the ladder leans on hardest.
- Entity lifecycle — assumes tickets and loading for what entity-ticking means.
- Synched entity data
- Attributes — watched immediately after synched entity data, the two being a pair.
- Movement and collision — needs authority in front of it, and blocks and states for what a collision shape is.
- AI: goals and brains — assumes environment attributes and timelines for the schedule and points of interest for the bed.
- Pathfinding — the other half of the same lecture, watchable on its own once goals and brains has run.
- Damage and death
Part VI must precede Part VIII, which is the player half of nearly every page here, and it should precede Parts IX and X, both of which lean on authority. It assumes Part III first — two of this part’s surprises are claims about which phase of the tick something ran in — then Part IV for what makes an entity tick at all and Part V for what it collides with.
VII · Items and inventories
Part VII is two tiers. The first three lectures are the vocabulary and are watched in order; the last five are three engines that touch each other only at their boundaries, so they can be watched in any order — though the two pairs below want to stay together, and contexts and predicates leans on no stack at all and could come first.
- Items and stacks — assumes data components completely.
- Using an item
- Containers and menus — needs both of Part III’s tick pages for when a broadcast happens: the packet drain at the top of the tick, the level’s entity phase, and a pickup in the connection phase after the levels.
- Recipes
- Enchantments
- Enchanting — watch it directly after enchantments.
- Contexts and predicates — the page in this part that Part XIII’s landing page names.
- Loot tables — watch it directly after contexts and predicates, which it is the worked example of.
Part VII assumes Part II for components, codecs and the reload, Part III for
where in a tick a packet is drained and a broadcast lands, and Part V for how a
chest gets opened. It must precede Part VIII, which is the player’s own inventory and
the swing that spends an item’s durability. Contexts and predicates is a
prerequisite of Part XIII’s /execute if predicate and of the advancement
system, and is the one lecture here a viewer coming for commands should
watch out of order.
VIII · The player
Lectures 1 and 2 are the trunk and are watched in that order; the five below them fall into four groups that need nothing from each other. Only one of the four has an internal order — the spear is the sword swing’s sequel — so 3, 6 and 7 can go anywhere after the trunk.
- Player anatomy — the vocabulary lecture.
- The two-phase tick — watch it immediately after player anatomy.
- Input to movement
- The sword swing
- The spear
- Hunger and experience
- Status effects
Part VIII assumes Part VI above everything — authority in particular, which is where the whole part’s premise is stated — and Part III for the tick phases the two-phase tick lives between. It assumes Part VII for using an item, which is the machinery both the spear’s charge and the meal run on; the inventory itself is Part VIII’s, and Part VII stops at the slot. Nothing later in the book is needed to watch it, but Parts X, XI and XIII come back to it.
IX · Networking
Part IX is the wire three times, and two things it carries. The first two lectures are one lecture in two halves and should be watched together; the third finishes the wire’s own story by showing the connection changing language, and the last two both sit inside the play language and need neither it nor each other.
- The connection
- Packets and stream codecs — the second half of the same lecture.
- Protocol phases
- What the client is told
- Chat and signing
Part IX assumes Part III for the tick phases its traffic is timed against,
and Part I’s anatomy
for the two loops, which is the fact most of what looks like network jitter
comes back to. It assumes Part II for codecs and for
Component, and Part VI’s authority for
the premise under lecture four. It is a prerequisite of Part X, which is the
same wire watched from the receiving end.
X · The client
Nothing here hands off to anything except inside the GUI stack, six to nine, and between two and three, which share a ledger; every other page after the first answers “when in the client’s one loop does this happen” and needs only the hub. Watch the hub first and then take the rest in any order that suits, except the three groups noted below.
- The client loop
- The client level
- Prediction and acknowledgement
- Input and keybinds
- Options
- GUI and screens
- The GUI render tree
- Text and fonts
- The HUD
- Sound: the engine
- What makes a sound happen
- Debugging the running game
Six to nine are the part’s one internal pipeline and are watched consecutively, tree before text. Two and three are a pair. Ten and eleven are the two halves of sound, in either order.
Part X assumes Part IX for the wire it is the far end of, and Part I’s anatomy for the two loops. It assumes Part VI’s authority as the premise under lectures two and three — which split its question between them, what the client may simulate and what it may show before it is told — and Part V before lecture three, because Part V’s landing page rules that its two click lectures come first and they are the ledger’s two applications. Lecture six assumes Part VII’s containers and menus, which is the thing behind the screen it opens on, and lecture eight assumes Part II’s text components. Lecture one is a prerequisite of Part XI, whose frame is one span inside the loop’s turn.
XI · Rendering
The part opens on the frame itself rather than on the substrate underneath it — an inversion made for the viewer rather than for the dependencies, like the late place of Part XII’s structure lectures — so the watching order below is not the order a frame runs in.
- The frame
- The window
- Blaze3D
- Visibility and the frame graph
- Section meshing
- Models and atlases
- Entity rendering
- Block-entity rendering
- Lightmap, fog and sky
- Particles
- Post-processing
Four and five are a pair — two pages that were one, and still one journey seen from its two ends — and so are seven and eight, the second written as the differences from the first. One to three can also be watched one, three, two.
Part XI assumes two pages of Part X: the client loop, which is what says when a frame happens, and the client level, for what the thing being drawn is. Lecture six assumes Part II’s resource system; lecture nine assumes Part IV’s environment attributes and timelines, which owns the system this part only consumes.
XII · World generation
The three structure lectures run first in the game and are watched last
here, bar lecture ten: a structure is decided at ChunkStatus.STRUCTURE_STARTS, two statuses
before the biomes it will stand in exist, and writes no block until
ChunkStatus.FEATURES, one status after the noise fill. Keeping them
together at the end costs two of the earlier pages a forward topic and buys
a whole arc in one place.
- Density functions
- Biomes
- Terrain
- Blending at the old-chunk border
- Features and placement
- Trees
- Structure placement
- Jigsaw and templates
- Hand-built structures
- Creating a world
One to six are the chunk being made, in the order it is made, and each from two on leans on one before it. Seven, eight and nine are one arc, and eight and nine are alternatives to each other rather than a sequence. Ten is the object the other nine read, told last because it is a tree of everything they explain — but it is the one lecture that moves, and a viewer who wants the origin before the machinery can watch it first.
Part XII assumes Part IV’s chunk generation pipeline, and hard: it is the only page that says when any of this runs and what the ten statuses are, and eight of the ten lectures here name one. It also assumes Part IV’s chunk anatomy for what is being written into and Part IV’s environment attributes and timelines for lecture two, where the biome is one layer of a modifier stack; and three Part II lectures — codecs, registries and the data-driven type pattern, whose fifty-six instances this part owns twenty-six of — because worldgen is the most thoroughly data-driven system in the game.
XIII · Commands and data packs
The dependency runs strictly one way: lectures 1 to 3, then 4 and 5, then the last four. Those last four are peers rather than a sequence — watch them in any order, or only the ones you care about — but none of the five before them is optional for any of them.
- Brigadier and commands
- Permissions
- Entity selectors
- The execution engine
- Functions and macros
- Advancements
- Scores, teams and stored data
- Dialogs
- Game tests
Two and four are the pair that most changes how a viewer reads everything else, and two is the one an existing mod author most needs; three sits between them because it needs the atom two defines and hands its fan-out to four. Six, seven, eight and nine each assume one through five and nothing else in this part.
Part XIII assumes Part III’s server tick twice over, because two of its pages turn on where in that order a thing sits. It assumes Part II’s codecs and the data-driven type pattern, of which dialogs and game tests are the two clearest instances; Part IX’s connection for the boundary between the Netty threads and the Server thread, which the command packets and the suggestion request each cross their own way; and Part VII’s contexts and predicates, which advancements need for every trigger that tests a player and selectors for one option.
The dependencies between parts
Every arc below is a before you start entry on a landing page: the part at the tail is one the part at the head assumes. The two dependencies the whole book leans on, whether or not a landing page lists them, keep their boxes and the one arc each along the spine, and have the rest of their arcs left off, because those would reach almost every node — Part I’s anatomy, for the threads every diagram’s lanes are on, and Part II’s codecs and registries, assumed wherever something is written to disk, sent on the wire or looked up by name. Two arcs out of Part II rest on neither — Part X’s on text components and Part XI’s on the resource system — and are left off with the rest. Read a solid arc as watch before: the parts run down the page in the sidebar’s order, and every solid arc runs down with them.
The thirteen parts top to bottom in watch order; an arc on the right runs from a part to one that assumes it, always downward, and the two dashed arcs on the left are the only places a part assumes a later one, each labelled with the pages it needs.
The graph is a line with two knots in it, and the sidebar order is a valid walk through it: no solid arc points at an earlier part. The two dashed arcs are the places where it does not hold, and each is cut on purpose rather than solved by reordering.
Part III assumes two pages of Part IV. The level tick uses entity-ticking and block-ticking range, which tickets and loading owns, and its first step throws away a cache belonging to environment attributes and timelines. The first is cut by definition — the level tick defines both ranges before it uses them — and the second by order: the environment page depends on nothing but registries and codecs, so it is the first page of Part IV in the sidebar, in that part’s watch order and in the list above, and it is the one lecture worth watching before its part.
Part V and Part X assume each other. The two click lectures in Part V are the applications of prediction and acknowledgement, and that page’s own scenario is a block placed against a wall, which needs Part V’s vocabulary. The cut is at Part V: both click pages state the ledger’s contract in the same four sentences near their top, which is all either needs, and the machinery waits for Part X, so the whole of Parts V and VI is watched before that one Part X lecture. Part VI hands something forward to two Part X pages: the client level opens by saying it is not an authority either, and prediction and acknowledgement answers the same question a second way, and both need authority behind them.
Ten pages carry most of the graph — nine rows below, because the two server ticks are one dependency in two lectures. The membership rule is mechanical: a page that two or more landing pages name under before you start, less anatomy, codecs and registries, the three pages the whole book leans on. A viewer who has watched these ten can take the parts they belong to in almost any order; a viewer who skips one of them will find a later part’s first surprise unexplained.
| the page | its part | the parts whose landing pages assume it |
|---|---|---|
| The server tick and the level tick | III | IV, V, VI, VII, VIII, IX, XIII — seven of the eight later parts on the server’s side of the game, for which phase something ran in |
| Environment attributes and timelines | IV | III, VI, XI, XII — the clock, the schedule, the colour of the sky, and the biome as one layer of the stack |
| Chunk anatomy | IV | V, VI, XII — a block state’s home, the heightmaps that decide where a mob may spawn, and what terrain is written into |
| Authority | VI | VIII, IX, X — the premise under every page about a player, and under what the client is told |
| The resource system | II | III, VII, XI — the staged load and its barrier: a server’s own data at startup, where recipes and loot tables come from, and the reload the atlases are built by |
| The connection | IX | X, XIII — the thread boundary between the network and the game, and the hop most packets make across it |
| Tickets and loading | IV | III, VI — what entity-ticking means |
| The data-driven type pattern | II | XII, XIII — the type field in a data-pack file and the registry it dispatches on; these two parts own most of its instances |
| Text components | II | IX, X — what a chat line and a screen’s label are before anything draws them |
Nineteen other system pages are named by exactly one landing page, and each belongs in that part’s before you start rather than in this table. Three are worth saying out loud anyway, because a viewer coming for one part alone most often has to fetch them from elsewhere in the book: blocks and states before Part VI, contexts and predicates before Part XIII’s advancements, and the client loop before Part XI.
Watched straight through, the sidebar order still needs one departure from itself, and it is now as small as it can be: environment attributes and timelines is the first lecture of Part IV and wants watching before Part III’s second. A viewer coming for one part rather than the whole book takes that part’s before you start list as the order.
The book’s two ends are chosen rather than inherited. What this book skips is the second lecture for the reason Part I’s entry gives; Part XIII’s game tests are the closing lecture because they are the game’s own answer to the question the whole book has been asking — how do you know what it does — and because nothing later depends on them.