<!-- https://minecraftdocs.dev/systems/world/tickets-and-loading — this page as markdown. The book's index: https://minecraftdocs.dev/llms.txt; the whole book in one file: https://minecraftdocs.dev/llms-full.txt -->

# 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

| class | what it decides | thread |
|---|---|---|
| `TicketStorage` | which tickets exist on which chunk, and which survive a restart — it is the *chunk_tickets* `SavedData` | Server |
| `DistanceManager` | owns both graphs, the two player-radius trackers and the four-slot throttle | Server |
| `LoadingChunkTracker` · `SimulationChunkTracker` | the two graphs: `ChunkTracker`s over `DynamicGraphMinFixedPoint`, one level higher per ring | Server |
| `ChunkLevel` | the number line — which level means which `FullChunkStatus` and which generation status | static |
| `ChunkHolder` | one chunk's level and its three futures | Server; futures complete on workers, confirmations hop back |
| `ChunkMap` | the holders (an updating map and a visible clone), each player's `ChunkTrackingView`, unloads | Server; workers read the visible map |
| `ServerChunkCache` | the level's `ChunkSource`: the tick slot, the synchronous `ServerChunkCache.getChunk`, and a `BlockableEventLoop` pinned to the Server thread | Server |
| `PlayerChunkSender` | the per-player batches, paced by the client's acknowledgements | Server |

## From a ticket to a future

```mermaid
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:

| type | timeout | loads | simulates | keeps dimension active | persists | who 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.PORTAL` | 300 | ✓ | ✓ | ✓ | ✓ | `Entity` on portal travel, radius 3 |
| `TicketType.ENDER_PEARL` | 40 | ✓ | ✓ | ✓ | | `ServerPlayer`, the pearl's chunk, radius 2 |
| `TicketType.DRAGON` | — | ✓ | ✓ | | | `EnderDragonFight`, radius 9 |
| `TicketType.PLAYER_SPAWN` | 20 | ✓ | | | | `PrepareSpawnTask` during configuration, radius 3 |
| `TicketType.SPAWN_SEARCH` | 1 | ✓ | | | | `PlayerSpawnFinder` |
| `TicketType.UNKNOWN` | 1 | ✓ | | | | 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](../server/server-level-tick#an-empty-dimension-skips-exactly-three-things)).
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 `ChunkStatus`es — 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 `ChunkTracker`s over the same `TicketStorage`, but each
reads only the tickets whose flags name it, and each is asked a different
kind of question:

| the question | who asks it | which graph answers |
|---|---|---|
| does a holder exist, and how far may it generate | `ChunkMap.updateChunkScheduling`, `GenerationChunkHolder.updateHighestAllowedStatus` | loading |
| may this chunk be sent to a player | `ChunkMap.onChunkReadyToSend`, from the level-32 future | loading |
| do its blocks and fluids tick | `ServerLevel.shouldTickBlocksAt` | simulation |
| do its entities tick | the entity loop, through `DistanceManager.inEntityTickingRange` | simulation |
| which chunks does the level walk for random ticks | `ChunkMap.forEachBlockTickingChunk`, a wrapper over `DistanceManager.forEachEntityTickingChunk` | simulation |
| may mobs spawn here | `DistanceManager.naturalSpawnChunkCounter`, feeding `ChunkMap.anyPlayerCloseEnoughForSpawning` | neither: 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.

```mermaid
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](chunk-generation-pipeline#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.

```mermaid
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](chunk-storage#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](../networking/what-the-client-is-told#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 moment | what the ticket system decides | the gate |
|---|---|---|
| the player crosses a section boundary | the two crescents: which chunks entered the view and which left | `ChunkMap.updateChunkTracking` returns early on the same centre and view distance, and `ChunkMap.applyChunkTrackingView` sends `ClientboundSetChunkCacheCenterPacket` only when the centre moved |
| a chunk enters the view | `ChunkMap.markChunkPendingToSend`, but only for a chunk that already exists | `ChunkMap.getChunkToSend` wants a ticking chunk; a fresh one waits for its promotion instead |
| a chunk reaches BLOCK_TICKING | `ChunkMap.onChunkReadyToSend` makes it pending for every player whose view holds it | `ChunkHolder.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 view | `ClientboundForgetLevelChunkPacket` | only 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 delivered | nothing at all | `ChunkMap.isChunkTracked` is false while the chunk sits in `PlayerChunkSender.pendingChunks`; the full chunk will carry it |
| the settings change | a radius moves, for every player at once | `PlayerList.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 ticket | dies when |
|---|---|
| no timeout — player, forced, dragon | its 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.UNKNOWN` | the 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, spawn | the 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 stops | every 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`.*
