Anatomy
Verified against Minecraft 26.2 · 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 — which is why a world published to LAN never pauses, however
deep in the options menu 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 | everything singleplayer does differently: 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 | Server, binding 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”.
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 below and on every renderer in Part XI. The rest of the drift a 1.21 reader will trip on is naming drift.
From main to a world
sequenceDiagram
participant Main as Main
participant RS as RenderSystem
participant MC as Minecraft
participant MS as MinecraftServer
participant IS as IntegratedServer
participant SCL as ServerConnectionListener
participant Conn as Connection
Main->>Main: tryDetectVersion, loadLibraries, DataFixers.optimize in the background, bootStrap, ClientBootstrap, validate
Main->>RS: initRenderThread — this thread is the Render thread from here on
Main->>MC: the constructor — initBackendSystem, a backend, a Window, every reload listener registered
MC->>MC: the first ReloadInstance — prepare on the workers, apply here, LoadingOverlay on screen
Main->>MC: run — pollEvents, then runTick, until running goes false
Note over Main,MC: one thread so far. The next line makes the second.
MC->>MS: doWorldLoad calls spin — the IntegratedServer is built here, the Server thread is started
MS->>IS: runServer calls initServer, which loads the level and prepares its chunks
MC->>MC: managedBlock — draw a frame, drain the queue, repeat, until MinecraftServer.isReady
MC->>SCL: startMemoryChannel — a Netty local address, no socket anywhere
MC->>Conn: connectToLocalServer — the client's end of that same channel
Conn->>SCL: handshake, then login — the handlers run on Netty, the login tick on the Server thread
Conn->>SCL: configuration, then play — from here the client is a client like any other
Note over MC,IS: two loops, one wire
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.
Bootstrap before anything exists. SharedConstants.tryDetectVersion
reads version.json as the first statement of both 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. That ordering is why nothing in world/ can be
touched from a static initialiser.
The GPU backend is chosen in the constructor.
RenderSystem.initBackendSystem runs first and returns GLFW’s clock, which
Minecraft installs through Util.setTimeSource — on the client, the game’s
entire notion of time comes from the windowing library. Then a GpuBackend is
chosen by trying candidates in an order Options sets until one of them
makes a Window — there are two, GlBackend and VulkanBackend
(the window)
— and from then on the renderer only ever sees the GpuDevice abstraction
in com/mojang/blaze3d.
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 the Render
thread keeps drawing frames and draining its own queue through
BlockableEventLoop.managedBlock until MinecraftServer.isReady — the
textbook case of waiting drains.
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 through
ClientHandshakePacketListenerImpl exactly as it would against a remote
server. Almost nothing in the play path knows it is singleplayer: the
exceptions are the handful of places that ask Connection.isMemoryConnection
directly, among them ClientPacketListener.handleUpdateTags, which skips
applying the tags it was sent because the server’s registries are already
the client’s. Protocol phases is that
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 LR
subgraph Client["the Render thread"]
direction TB
CR["Minecraft.run: RenderSystem.pollEvents"] --> CD["runTick: the DeltaTracker says how many whole ticks are owed"]
CD --> CP["PacketProcessor.processQueuedPackets"]
CP --> CQ["BlockableEventLoop.runAllTasks: this thread's own queue"]
CQ --> CT["Minecraft.tick, run 0 to 10 times"]
CT --> CF["renderFrame, interpolating by the leftover partial tick"]
CF --> CR
end
subgraph Wire["the Netty event loop"]
direction TB
N["Connection.channelRead0 decodes and calls the PacketListener. PacketUtils.ensureRunningOnSameThread queues it on the owner and aborts the handler"]
end
subgraph Server["the Server thread"]
direction TB
SR["MinecraftServer.runServer: the next deadline is set first"] --> SP["processPacketsAndTick: PacketProcessor.processQueuedPackets"]
SP --> SS["MinecraftServer.tickServer: every ServerLevel, then the connections"]
SS --> SW["waitUntilNextTick: run queued tasks, then park until the deadline"]
SW --> SR
end
N -- "a clientbound packet" --> CP
N -- "a serverbound packet" --> SP
CT -- "Connection.send" --> N
SS -- "Connection.send" --> N
The frame loop. Minecraft.run polls GLFW 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. The fractional remainder is the partial tick the renderers
interpolate with. 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 while sprinting — 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 any other thread that wants to touch that half’s state submits a
task and waits. 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 cores. |
| Server thread | MinecraftServer.spin | MinecraftServer.runServer | One per server, so singleplayer has exactly one. Priority 8, on the same more-than-four-cores 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. The two ConsecutiveExecutor classes 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. PriorityConsecutiveExecutor adds a priority to
the same idea. 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
Nothing on the client writes server world state and nothing on the server
writes client world state. Every block, entity and inventory change 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 — every one of them a setting
rather than world state. The server reads Minecraft.isPaused and the
client’s render and simulation distances every tick;
IntegratedServer.updateCommandsAllowedForOtherPlayers reaches into
LocalPlayer.setPermissions; the options screens call
IntegratedServer.publishServer and its siblings straight from the Render
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 the world, 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.
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.
ServerTickRateManager, over the shared TickRateManager, owns the
nanoseconds-per-tick, the freeze and the sprint state that /tick
manipulates, and the client mirrors it in ClientLevel so the
DeltaTracker can freeze too.
Does a busy server skip work? Less than the budget’s name suggests.
MinecraftServer.haveTime travels 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.
What happens when something throws? It is collected, not thrown. Both
loops catch everything and wrap it in a CrashReport; a background thread
that dies has its report parked for a loop to pick up
(how a server dies
owns that relay, and
the client loop
the client’s own three exits). The Part I consequence is the asymmetry: only
a loop constructed to propagate crashes rethrows a parked report, and
IntegratedServer is not one — so a worker that dies in singleplayer
surfaces on the client, not on the server thread whose work it was doing.
Which entry point starts all this? One of five. client/main/Main for the
client, server/Main for the dedicated server, data/Main for the data
generator, client/data/Main for the generated client assets — models,
atlases, equipment assets, waypoint styles — and gametest/Main for
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, server.properties through
DedicatedServerProperties on the dedicated server, and version.json
through SharedConstants on both. The two generator entry points are
build-time programs and
what this book skips
says how far that is true.
Where to look
client/main/Main · GameConfig · Minecraft · DeltaTracker ·
MinecraftServer · IntegratedServer · server/Main · DedicatedServer ·
GameTestServer · BlockableEventLoop · ReentrantBlockableEventLoop ·
Util (the executors) · PacketProcessor · PacketUtils ·
EventLoopGroupHolder · ServerConnectionListener · Connection ·
PreferredGraphicsApi · GpuBackend
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.