Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

classwhat it decidesthread
Minecraftthe client: the Window, the resource system, the renderers, input and Options — and, in three fields, whether we are in a world at allRender
MinecraftServerthe world and the loop that advances it. Abstract, with three concrete subclasses: IntegratedServer, DedicatedServer, and GameTestServer, the headless harness the gametest entry point launchesServer
IntegratedServersingleplayer’s server: the pause, LAN publishing, the player cap, the relaxed limitsServer
BlockableEventLoopthe queue-and-thread pairing both loops are — Minecraft and MinecraftServer each extend ReentrantBlockableEventLoopone per queue, and a thread may own more than one
Connectionone channel, and which PacketListener is currently on itNetty
ServerConnectionListenerwhich channels the server listens on, including the in-memory one singleplayer usesthe binding thread (Render in singleplayer), into Netty
PacketProcessorwhich decoded packets are waiting to be handled on the thread that owns their statefilled from Netty, drained by the owner
Utilthe 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

threadmade byrunsnotes
Render threadthe JVM main thread, renamed in client/main/MainMinecraft.runAlso the client’s game thread: Minecraft.gameThread is this thread. Priority 10 on machines with more than four processors.
Server threadMinecraftServer.spinMinecraftServer.runServerOne per server, so singleplayer has exactly one. Priority 8, on the same more-than-four-processors condition.
Netty IOEventLoopGroupHolderthe Connection pipelineNamed 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-nUtil.backgroundExecutora ForkJoinPool sized to the JDK’s available-processor count minus oneUtil.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 a DeltaTracker.Timer you 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.