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.