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

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

classwhat it decidesthread
TicketStoragewhich tickets exist on which chunk, and which survive a restart — it is the chunk_tickets SavedDataServer
DistanceManagerowns both graphs, the two player-radius trackers and the four-slot throttleServer
LoadingChunkTracker · SimulationChunkTrackerthe two graphs: ChunkTrackers over DynamicGraphMinFixedPoint, one level higher per ringServer
ChunkLevelthe number line — which level means which FullChunkStatus and which generation statusstatic
ChunkHolderone chunk’s level and its three futuresServer; futures complete on workers, confirmations hop back
ChunkMapthe holders (an updating map and a visible clone), each player’s ChunkTrackingView, unloadsServer; workers read the visible map
ServerChunkCachethe level’s ChunkSource: the tick slot, the synchronous ServerChunkCache.getChunk, and a BlockableEventLoop pinned to the Server threadServer
PlayerChunkSenderthe per-player batches, paced by the client’s acknowledgementsServer

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:

typetimeoutloadssimulateskeeps dimension activepersistswho 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.PORTAL300✓✓✓✓Entity on portal travel, radius 3
TicketType.ENDER_PEARL40✓✓✓ServerPlayer, the pearl’s chunk, radius 2
TicketType.DRAGON—✓✓EnderDragonFight, radius 9
TicketType.PLAYER_SPAWN20✓PrepareSpawnTask during configuration, radius 3
TicketType.SPAWN_SEARCH1✓PlayerSpawnFinder
TicketType.UNKNOWN1✓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 questionwho asks itwhich graph answers
does a holder exist, and how far may it generateChunkMap.updateChunkScheduling, GenerationChunkHolder.updateHighestAllowedStatusloading
may this chunk be sent to a playerChunkMap.onChunkReadyToSend, from the level-32 futureloading
do its blocks and fluids tickServerLevel.shouldTickBlocksAtsimulation
do its entities tickthe entity loop, through DistanceManager.inEntityTickingRangesimulation
which chunks does the level walk for random ticksChunkMap.forEachBlockTickingChunk, a wrapper over DistanceManager.forEachEntityTickingChunksimulation
may mobs spawn hereDistanceManager.naturalSpawnChunkCounter, feeding ChunkMap.anyPlayerCloseEnoughForSpawningneither: 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):

  1. The tick. ServerChunkCache.tick, from the level tick, runs TicketStorage.purgeStaleTickets and then ServerChunkCache.runDistanceManagerUpdates.
  2. Idle time. When the server’s own queue has nothing to run and the server is ahead of its schedule, blocked or sprinting, MinecraftServer.pollTaskInternal offers the levels’ ServerChunkCache.MainThreadExecutor.pollTask a turn, one after another until one runs something; that poll runs the distance updates first and, if they did any work, returns at once.
  3. A synchronous ask. ServerChunkCache.getChunk from anywhere on the Server thread checks a four-entry cache, then ServerChunkCache.getChunkFutureMainThread adds a TicketType.UNKNOWN ticket 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 momentwhat the ticket system decidesthe gate
the player crosses a section boundarythe two crescents: which chunks entered the view and which leftChunkMap.updateChunkTracking returns early on the same centre and view distance, and ChunkMap.applyChunkTrackingView sends ClientboundSetChunkCacheCenterPacket only when the centre moved
a chunk enters the viewChunkMap.markChunkPendingToSend, but only for a chunk that already existsChunkMap.getChunkToSend wants a ticking chunk; a fresh one waits for its promotion instead
a chunk reaches BLOCK_TICKINGChunkMap.onChunkReadyToSend makes it pending for every player whose view holds itChunkHolder.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 viewClientboundForgetLevelChunkPacketonly 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 deliverednothing at allChunkMap.isChunkTracked is false while the chunk sits in PlayerChunkSender.pendingChunks; the full chunk will carry it
the settings changea radius moves, for every player at oncePlayerList.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 ticketdies when
no timeout — player, forced, dragonits 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.UNKNOWNthe 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, spawnthe 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 stopsevery 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 (in world/level, not server/level) is the saved data now. The two graphs are separate top-level classes, not inner classes of DistanceManager, and the whole ChunkHolder generation 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.