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

The client loop

Verified against Minecraft 26.2 · 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: they are already gone from the residual, nothing will ever run them, and the world you are standing in has skipped forward without simulating the gap. 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 client does it on any frame that needs to, at a ceiling of ten, and says nothing.

Everything on this page is one thread. The thread named "Render thread" is the main thread — Main.main renames it and RenderSystem.initRenderThread claims it — so Minecraft.gameThread, BlockableEventLoop.isSameThread and RenderSystem.assertOnRenderThread all agree about the same thread. There is no render thread, and there never was one in this version.

The cast

classwhat it decidesthread
Minecraftthe loop itself, and — being a ReentrantBlockableEventLoop — the main thread’s task queueRender thread
DeltaTracker.Timerhow many whole ticks this frame owes, and what the leftover fraction isRender thread
PacketProcessorwhere packets decoded on Netty threads wait to be appliedfilled on Netty, drained here
TickRateManagerthe millisecond target the Timer divides by — a server objectread here, owned there
FramerateLimitTrackerwhat the frame cap actually is, which is not always the optionRender thread
FramerateLimiterthe park that enforces itRender thread
Mainthe process: the config, the shutdown hook, the thread’s nameJVM 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 — GLFW callbacks run here, inline, on this thread"]
    PRE["Pre render: Window.shouldClose, then any pending resource reload"]
    ASK["DeltaTracker.Timer.advanceGameTime — how many whole ticks has the clock owed since last time?"]
    DRAIN["PacketProcessor.processQueuedPackets, then BlockableEventLoop.runAllTasks"]
    TEX["TextureManager.tick — once, and only if ticks are owed and the level is running normally"]
    CLAMP{"more than ten ticks owed?"}
    DROP["the excess is already out of the residual — nothing will ever run it"]
    TICK["Minecraft.tick, up to ten times"]
    PREFRAME["SoundManager.updateSource, then MouseHandler.handleAccumulatedMovement"]
    FRAME["Render: renderFrame — its frameLimiter zone parks for the cap, then fpsUpdate samples the counters"]
    POST["Post render: recompute Minecraft.pause, update the timer's pause and freeze"]
    POLL --> PRE --> ASK --> DRAIN --> TEX --> CLAMP
    CLAMP -- "yes" --> DROP --> TICK
    CLAMP -- "no" --> TICK
    TICK --> PREFRAME --> FRAME --> POST
    POST -- "next iteration of Minecraft.run" --> POLL

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.

The quoted phrases are Window.setErrorSection calls, the crash report’s breadcrumb, so a client that dies takes Pre render, Render or Post render to the report with it. What happens inside the frame is the frame; this page stops at the profiler’s frame zone. 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 — the ceiling, named by Minecraft.MAX_TICKS_PER_UPDATE, though the clamp in Minecraft.runTick is written as a literal and no reader of the constant survives the decompile. javac inlines a static final int at every use site, so a decompile can never tell a documented constant from a dead one; what it does show is that the number the loop obeys is the literal.

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. /tick rate is a server command that changes the arithmetic inside the client’s frame loop. /tick freeze is the same lever pulled the other way, 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 is what a menu animates against while the world is stopped. 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 and its order is a dependency order. It advances Minecraft.clientTickCount; then, when there is a level and the game is not paused, it ticks the TickRateManager. Then in sequence: the game mode; Minecraft.pick at a partial tick of one; Tutorial.onLookAt with the result; the GUI block (TextInputManager, then Gui.tick, with Minecraft.missTime pinned high while a screen is open); the keybind drain, only when there is neither an overlay nor a screen; then GameRenderer.tick, ClientLevel.tickEntities and Level.tickBlockEntities; then the music and sound managers, which sit outside the level check and run with no world at all; then the level block — the first-server toast, Tutorial.tick, and then ClientLevel.tick alone inside a crash-report handler; then ClientLevel.animateTick and ParticleEngine.tick, both additionally gated on the level running normally; then ServerboundClientTickEndPacket; and last of all KeyboardHandler.tick, where the F3+C crash countdown lives.

With no level that whole middle collapses, but not into one branch: two separate else arms at two points in the method clear any post-effect and tick the pending connection, with the unconditional music and sound managers running between them.

Two orderings in that list are load-bearing elsewhere in the book. ServerboundClientTickEndPacket goes out once per unpaused client tick that has a connection, and the server reads it to decide that a player who sent no movement this tick is standing still. And Minecraft.pick 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

Four queues and one re-entry that is not a queue.

  • Packets decoded on Netty threads are parked by PacketProcessor.scheduleIfPossible and 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 queued task is already running, because ReentrantBlockableEventLoop.scheduleExecutables returns true for the whole of ReentrantBlockableEventLoop.doRunTask, which is what stops a task from re-entering itself. GLFW callbacks, dispatched inside RenderSystem.pollEvents, are not inside one, so they execute before the tick that will observe them — see input and keybinds.
  • Section meshing goes to Util.backgroundExecutor and is collected by SectionRenderDispatcher (Part XI).
  • GPU work registered with RenderSystem.queueFencedTask is picked up by RenderSystem.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.
  • And BlockableEventLoop.managedBlock pumps tasks while the loop is blocked waiting for the integrated server — the mechanism server-tick owns.

The profiler wraps all of it. Minecraft.constructProfiler picks per iteration between InactiveProfiler, the frame-profile ContinuousProfiler behind the F3 pie chart, the MetricsRecorder and a SingleTickProfiler, and Minecraft.finishProfilers closes it. 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 defers the keep-alive reply while the poll is blocked, so that dragging the window does not look to the server like a network stall.

Pausing, which is two things and neither is the menu

Minecraft.pauseIfInactive, called during the frame, pauses the game when the window has been unfocused for more than half a second and Options.pauseOnLostFocus is on. Minecraft.pause — the field — is recomputed at the very end of Minecraft.runTick as singleplayer, and the GUI says we are pausing, and the world is not open to LAN. Gui.isPausing asks the current screen and overlay, and Screen.isPauseScreen defaults to true: this is why the options screen stops a singleplayer world and a chest does not — AbstractContainerScreen overrides it to false. On the rising edge the loop calls SoundManager.pauseAllExcept, sparing music and UI sounds, and hands the new 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 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 numbers on the F3 screen are three different measurements and it is worth knowing which is which. Minecraft.fps is a static field sampled once a second. 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. The graph uses wall-clock between frames, measured after the limiter, so it includes the sleep.

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 — which is why every OptionInstance.set performed while loading options.txt silently skips its listener (see options). Main.main then calls Minecraft.exitWorldAndClose, and its last statement arms ClientShutdownWatchdog.startShutdownWatchdog over what follows.

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 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 two texture managers, resources, the Tracy capture, the narrator, FreeType, the executors, the surface and the renderer — and then, in a finally block, only the window, the monitor manager and GLFW’s own termination. 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 (now DeltaTracker.Timer), 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.