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

Entity lifecycle

Verified against Minecraft 26.2 · Part VI · A zombie spawns in a dark chunk at night, is ticked for a while, and then either despawns or is written to disk when the chunk unloads.

Night falls, you are standing in a field, and somewhere behind you a zombie appears. What the server actually did is smaller and stranger than it looks. Once a tick, for each chunk near a player and each mob category still under its cap, NaturalSpawner.getRandomPosWithin rolls a random x, a random z and one y — a single uniform draw between the world bottom and the surface height of that column. Three group attempts follow, and they jitter only x and z. So the whole of one category’s chance in one chunk this tick lives on one horizontal slice: every eligible category gets its own slice, caves and the open field compete for the same rolls, and a world with more vertical space between bedrock and grass spreads those rolls thinner over the surface. Everything else on this page — the caps, the light test, the despawn radius, the write to disk — hangs off that one roll, or off the moment several rejections later where the mob is finally allowed to exist.

The cast

classwhat it decidesthread
NaturalSpawnerevery test between a chunk and a mob, in one file of static methodsserver main
NaturalSpawner.SpawnStatethe per-tick census, the global cap per MobCategory, and the biome energy budget through PotentialCalculatorserver main, rebuilt each tick
LocalMobCapCalculatorwhether any player near this chunk is still under the per-player limitserver main, rebuilt each tick
SpawnPlacementsthe placement type, heightmap and predicate for each EntityType — code, not dataa static table, read on the server main thread
PersistentEntitySectionManagerwhich entities exist, which are findable, which tick, which chunks are queued to unloadserver main, with one concurrent load inbox
Visibilitythe three-state projection of FullChunkStatus that everything above readsan enum, read on both sides
EntityTickListthe set the tick walks, and the double buffer that makes mutating it mid-walk safeserver main, and the client’s main thread for ClientLevel
EntityStoragethe entities/ region files, separate from the block onesreads on the IO pool, deserialises and writes on the server main thread

A spawn attempt is a filter, not a conversation

Almost every step of the spawner is a rejection. Drawing it as a conversation hides that, so here it is as the cascade it is — top to bottom in the order the code runs, with everything that can drop an attempt drawn as an arrow leaving the path.

flowchart TD
    T["ServerChunkCache.tickChunks, once a tick"] --> ST["NaturalSpawner.createState walks every entity in the level, skipping MISC and persistent mobs"]
    ST --> CAT{"NaturalSpawner.getFilteredSpawningCategories"}
    CAT -->|"a monster category, and the monster game rules are off"| X1["this category is dropped from the tick's list"]
    CAT -->|"a persistent category, and game time is not a multiple of 400"| X1
    CAT -->|"already at the global cap for the category"| X1
    CAT -->|"eligible"| CH{"ChunkMap.collectSpawningChunks, then shuffled"}
    CH -->|"no ticking chunk there"| X2["this chunk is skipped"]
    CH -->|"ChunkMap.playerIsCloseEnoughForSpawning fails, meaning no non-spectator player within 128 blocks measured horizontally to the chunk centre"| X2
    CH --> TICK{"ServerLevel.canSpawnEntitiesInChunk"}
    TICK -->|"not entity-ticking, or outside the world border"| X2
    TICK --> LOC{"LocalMobCapCalculator.canSpawn, per category"}
    LOC -->|"every nearby player is at their own cap, or there is no nearby player at all"| X2
    LOC --> POS["NaturalSpawner.getRandomPosWithin, a random x and z and ONE y between the world bottom and the surface"]
    POS -->|"the roll landed at the very bottom"| X2
    POS --> RC{"is the block at that position a redstone conductor?"}
    RC -->|"yes, before any species is picked"| X2
    RC -->|"no"| JIT["three group attempts, jittering only x and z, the y fixed"]
    JIT --> NP{"a non-spectator player anywhere in the level?"}
    NP -->|"no"| X3["this attempt is dropped"]
    NP --> D{"NaturalSpawner.isRightDistanceToPlayerAndSpawnPoint"}
    D -->|"within 24 blocks of the nearest player"| X3
    D -->|"within 24 of the respawn point, and the respawn point is in this dimension"| X3
    D -->|"jittered into a neighbouring chunk that cannot spawn"| X3
    D --> PICK{"NaturalSpawner.getRandomSpawnMobAt, weighted, once per group"}
    PICK -->|"the list is empty, or a reduced-water-ambient biome, 98 per cent of the time"| X3
    PICK --> TY{"NaturalSpawner.isValidSpawnPostitionForType, the typo is Mojang's"}
    TY -->|"the category is MISC"| X3
    TY -->|"too far out for a type that cannot spawn far from a player"| X3
    TY -->|"unsummonable, or the species is no longer in the list at this exact block"| X3
    TY -->|"SpawnPlacements.isSpawnPositionOk fails on the placement type"| X3
    TY -->|"SpawnPlacements.checkSpawnRules fails, and this is where the light test lives"| X3
    TY -->|"the type's spawn box collides with the world"| X3
    TY --> BUD{"NaturalSpawner.SpawnState.canSpawn, the biome energy budget"}
    BUD -->|"over budget"| X3
    BUD --> MAKE["EntityType.create, and ONLY NOW does a Mob object exist"]
    MAKE -->|"feature-flagged off, or Peaceful and not allowed there"| X4["this category's attempt on this chunk returns"]
    MAKE --> OBS{"Mob.checkSpawnRules and Mob.checkSpawnObstruction, on the real object"}
    OBS -->|"either fails, or it would despawn instantly anyway"| X3
    OBS --> FIN["Mob.finalizeSpawn, then addFreshEntityWithPassengers, then SpawnState.afterSpawn"]

The boundary that matters is the one marked only now. Everything above it is decided against the EntityType — the placement type, the heightmap (chunk anatomy), the light rule, the collision box — because constructing a mob to ask it costs more than answering from the type. Nothing above that line has an object to call a method on. Monster.checkMonsterSpawnRules is the light rule for a zombie, and it hands the light half to Monster.isDarkEnoughToSpawn, three tests in a row: sky light against a random draw from zero to 31, then the dimension’s DimensionType.monsterSpawnBlockLightLimit if that limit is below 15, then the local brightness against a sample of DimensionType.monsterSpawnLightTest. The last of those is where storms come in: during thunder the brightness is computed with a fixed sky-darkening of 10 instead of the level’s current one, which is what lets monsters spawn outdoors in the daytime. The light rule is per-dimension data, not a constant, and EntitySpawnReason.ignoresLightRequirements exempts exactly one reason, EntitySpawnReason.TRIAL_SPAWNER.

Construction is itself a filter, and the harshest-tempered one: EntityType.create returns null when the type is feature-flagged off or the difficulty is Peaceful and the type is not EntityType.isAllowedInPeaceful, and NaturalSpawner.spawnCategoryForPosition answers a null by returning outright — not by trying the next position. On Peaceful the spawner does all the work up to construction and then abandons this category’s attempt.

The two caps, and where 289 comes from

A mob must pass both caps, and they are counted differently. The global cap in NaturalSpawner.SpawnState.canSpawnForCategoryGlobal is MobCategory.getMaxInstancesPerChunk — 70 for MobCategory.MONSTER, 10 for MobCategory.CREATURE — times the number of spawnable chunks, divided by NaturalSpawner.MAGIC_NUMBER. That divisor is 17², and the 17 is not arbitrary: DistanceManager tracks spawn chunks out to eight chunks from each player over a neighbourhood that includes diagonals, so one player contributes a Chebyshev square of 17×17 chunks. The constant normalises the cap back into seventy monsters per player’s worth of area, which is why it grows with player count and shrinks when players stand together.

The local cap is LocalMobCapCalculator.canSpawn, and it is a veto rather than a budget: it walks the players near this chunk and answers yes the moment it finds one under the raw per-chunk number for the category. With no player near the chunk the walk finds nobody and the answer is no. (SharedConstants.DEBUG_IGNORE_LOCAL_MOB_CAP is the development switch that turns that half off.) The census both caps count from skips any mob that is Mob.isPersistenceRequired or Mob.requiresCustomPersistence — named, leashed or ridden — so a named zombie costs nothing against either cap. That is the same predicate pair that makes Mob.checkDespawn return early, which is why name it and it stays and name it and it stops counting are one fact and not two.

The four constants that are not the numbers

NaturalSpawner declares NaturalSpawner.MIN_SPAWN_DISTANCE 24, NaturalSpawner.SPAWN_DISTANCE_CHUNK 8 and NaturalSpawner.SPAWN_DISTANCE_BLOCK 128, and not one of the three is read anywhere in the game — the live values are the literals 576.0 and 16384.0 at their use sites, both already squared. The two that are read are NaturalSpawner.MAGIC_NUMBER and one more. That one, NaturalSpawner.INSCRIBED_SQUARE_SPAWN_DISTANCE_CHUNK, is neither 8 nor 24: it is the floor of 8 divided by the square root of two, so 5, and DistanceManager.hasPlayersNearby uses it as the fast yes of a three-way answer — inside 5 chunks certainly near, beyond 8 certainly not, and in between fall through to the real per-player distance test. Reading a name and believing the number is how a page gets this wrong.

What finalizeSpawn settles for the whole pack

Mob.finalizeSpawn adds a triangular random bonus to Attributes.FOLLOW_RANGE under Mob.RANDOM_SPAWN_BONUS_ID and rolls a 5 % chance of left-handedness. Zombie.finalizeSpawn then rolls loot-pickup and door-breaking against local difficulty, equipment and its enchantments, and — the part players notice — returns a Zombie.ZombieGroupData that the loop feeds back into the next mob of the same group. Baby-or-adult is decided once, by the first zombie, and inherited by the rest: a spawn group is all-baby or all-adult, never mixed. A baby gets a 5 % roll at an existing unridden Chicken in a box five blocks wide and three tall, and only if that roll fails a second 5 % roll to create one. Two different limits end it. Mob.isMaxGroupSizeReached breaks the current group and lets the next of the three attempts start; Mob.getMaxSpawnClusterSize returns outright and kills all three. The base value is four, and seven species change it — horses to 6, fish and wolves to 8, and ghasts, happy ghasts and pillagers down to 1.

The other ways in

Natural spawning is one caller of LevelWriter.addFreshEntity among many. BaseSpawner drives the SpawnerBlockEntity and TrialSpawner the trial chambers (block entities); SpawnEggItem and SummonCommand are the deliberate ones; AgeableMob.getBreedOffspring makes babies; and five CustomSpawner implementations — PhantomSpawner, PatrolSpawner, CatSpawner, WanderingTraderSpawner and VillageSiege — are ticked as a list by ServerLevel.tickCustomSpawners after the chunks. Each stamps one of the nineteen EntitySpawnReason constants, though not a distinct one — phantoms and cats both count as natural, sieges and wandering traders both as event — and that reason never leaves the server: nothing about why something spawned crosses the wire.

Entry: what addFreshEntity actually does

LevelWriter.addFreshEntity is a default method that returns false. Level does not override it and neither does ClientLevel. Exactly two classes do. ServerLevel.addFreshEntity is the one this page is about. WorldGenRegion.addFreshEntity is the other, and it does something entirely different: it writes the entity straight into the ChunkAccess’s own list and never touches PersistentEntitySectionManager at all. That is the EntitySpawnReason.CHUNK_GENERATION path — worldgen mobs are parked in the proto-chunk as NBT and only enter the manager later, when the chunk is promoted and PersistentEntitySectionManager.addWorldGenChunkEntities is handed them (the generation pipeline). On the client the only way in is ClientLevel.addEntity, called from the packet handler, and it begins by removing whatever already holds that network id.

sequenceDiagram
    participant SL as ServerLevel
    participant PESM as PersistentEntitySectionManager
    participant CM as ChunkMap
    participant ETL as EntityTickList
    participant Mob as Mob
    participant ES as EntityStorage
    participant Wire as the network

    Note over SL: the tick it is created
    SL->>SL: addFreshEntityWithPassengers walks getSelfAndPassengers, vehicle first
    SL->>PESM: addNewEntity
    PESM->>PESM: claim the UUID, put it in its EntitySection, install the Callback
    PESM->>SL: LevelCallback.onCreated
    PESM->>CM: startTracking, through ServerChunkCache.addEntity
    CM->>Wire: ClientboundAddEntityPacket, bundled with data, attributes, equipment, passengers and leash
    PESM->>ETL: startTicking, EntityTickList.add
    Note over SL: every later tick
    SL->>Mob: checkDespawn, for every entry in the tick list
    SL->>Mob: tickNonPassenger, only when the chunk is in entity-ticking range
    Note over PESM: the tick the chunk drops to hidden
    PESM->>ETL: stopTicking, EntityTickList.remove
    PESM->>CM: stopTracking
    CM->>Wire: ClientboundRemoveEntitiesPacket
    Note over PESM: some later PersistentEntitySectionManager.tick
    PESM->>ES: storeEntities, and only then UNLOADED_TO_CHUNK

ServerLevel.EntityCallbacks is the class those callbacks land in — five of LevelCallback’s seven appear in the figure — and it is where a surprising amount of the level hangs: the scoreboard entry, the players list and the sleeping-player recount, waypoint tracking, the navigating-mob set the block-change notifier walks, the EnderDragonPart id registrations, and the dynamic DynamicGameEventListener registration (game events).

Findable, ticking, or neither

Visibility is the whole idea in three constants, and Visibility.fromFullChunkStatus is the projection: FullChunkStatus.FULL makes a chunk’s entities findable, FullChunkStatus.ENTITY_TICKING makes them tick, anything less hides them (tickets and loading).

stateDiagram-v2
    state "Visibility.HIDDEN" as H
    state "Visibility.TRACKED" as T
    state "Visibility.TICKING" as K
    [*] --> H
    H --> T : chunk reaches FULL, startTracking adds it to EntityLookup and ChunkMap sends the ClientboundAddEntityPacket bundle
    T --> K : chunk reaches ENTITY_TICKING, startTicking adds it to EntityTickList
    K --> T : below ENTITY_TICKING, stopTicking removes it from EntityTickList
    T --> H : below FULL, stopTracking sends ClientboundRemoveEntitiesPacket and the chunk key joins chunksToUnload
    K --> H : straight down in one call, stopTicking first and stopTracking second
    H --> [*] : a later manager tick writes the section and marks UNLOADED_TO_CHUNK
    note right of H : hidden is not written yet. The client was told at the status change, the disk hears several ticks later.

The asymmetry is real and worth stating precisely. PersistentEntitySectionManager.updateChunkStatus runs its four tests in a fixed order — stop ticking, stop tracking, start tracking, start ticking — so on the way up an entity becomes trackable before it becomes tickable, and on the way down it stops ticking before it stops being tracked. That order holds only on the chunk-status path. The other transition path, an entity walking across a section boundary into a differently-statused section, runs through PersistentEntitySectionManager.Callback instead, which does tracking first in both directions and then ticking, and fires LevelCallback.onSectionChange at the end. And the always-ticking exemption, Entity.isAlwaysTicking, which lifts an entity clear of every one of those filters, is claimed by exactly one class in 26.2: Player.

The tick it gets, and the despawn check it gets anyway

The entity block of ServerLevel.tick is skipped in its entirety once the level has gone 300 ticks without an active ticket. Otherwise the tick walks EntityTickList and, for each entry that is neither removed nor frozen by TickRateManager.isEntityFrozen, calls Entity.checkDespawn — whose base implementation is empty, overridden only by Mob, EnderDragon, WitherBoss and ShulkerBullet, so for an item or an arrow it is a no-op call. Only after that does the range test run: a ServerPlayer is exempt outright, everything else needs DistanceManager.inEntityTickingRange for its own chunk, and an entity already riding a live vehicle returns here to be ticked by ServerLevel.tickPassenger instead.

So despawn is checked for every member of the tick list while ticking is not. The two sets should agree, and mostly do — the tick list is the ticking set — but they read different sources: membership follows the chunk holder’s promoted FullChunkStatus, while the per-tick gate reads the simulation tracker directly, and the two need not have converged in the same tick.

EntityTickList is what makes that walk safe. It holds two id-keyed maps and a nullable reference to whichever one is being iterated. Adding or removing during a walk copies the live map into the spare and swaps them, so the in-flight iterator finishes over the original, untouched map and the mutation lands in the new one. A second concurrent walk is refused outright, by checking that reference rather than a boolean.

Ending one: Mob.checkDespawn

Left in a loaded chunk, the zombie ends through Mob.checkDespawn, whose first branch consults no player at all: on Peaceful, anything whose type is not EntityType.isAllowedInPeaceful is discarded on the spot, ahead of even the persistence check. Past that, a persistent mob has its LivingEntity.noActionTime pinned to zero and is done.

Everything else is measured against the nearest non-spectator player — and if there is no player in the level at all, both remaining branches do nothing, so a mob alone in a world never despawns by distance. Beyond MobCategory.getDespawnDistance — 128 blocks for every category except MobCategory.WATER_AMBIENT, which is 64 — it is discarded instantly. Beyond MobCategory.getNoDespawnDistance, a flat 32 for every category, it is discarded on a 1-in-800 roll, but only once LivingEntity.noActionTime has passed 600; inside that 32 the same method resets that counter to zero, so standing near a mob keeps it alive. Both distance branches also require Mob.removeWhenFarAway, the per-species veto — and it is broader than people expect: Animal returns false for every animal, tamed or not, and Villager for every villager, so a wild cow on a hilltop never despawns by distance at all. That is why 128 blocks and it is gone is a species-dependent rule and not a universal one. What both branches call is Entity.discard, which destroys and does not save.

Ending two: the chunk goes away

Walk far enough instead and the chunk falls out of entity-ticking: the zombie stops ticking but stays findable. Fall to Visibility.HIDDEN and two things happen, several ticks apart. At the status change, PersistentEntitySectionManager.updateChunkStatus stops ticking and stops tracking the section’s entities, and stopping tracking is what reaches ChunkMap and sends ClientboundRemoveEntitiesPacket — the client is told then, not at the write. The chunk key goes into the manager’s unload set, and some later PersistentEntitySectionManager.tick runs PersistentEntitySectionManager.processUnloads over it.

That later step is not a formality, and it can refuse. A chunk whose entity data is still being read back off disk is deferred to a future tick. A chunk that has entities to save but has never been read is loaded first, so the two sets can be merged — the unload triggers a load. Only then does EntityStorage.storeEntities write the Entities list and a Position into the entities/ region files (chunk storage), which are separate from the block region/ files and which remember the chunks that came back empty so they are never re-read. Each saved entity and its passengers then take Entity.RemovalReason.UNLOADED_TO_CHUNK and drop their level callback.

Two rules decide what is in that file. Passengers are written inside their vehicle, never beside it, so Entity.shouldBeSaved refuses any entity that is riding something; and a vehicle whose passengers are exactly one player is refused too, because it travels in that player’s own data instead. The clause that is easy to miss is the first one in the method: an entity already carrying a non-saving removal reason is skipped, which is what keeps a discarded mob still sitting in a section out of the file.

Five reasons, one label

reasondestroyssaveswhat leaves it behind
Entity.RemovalReason.KILLEDyesnodeath, in every sense the game means it
Entity.RemovalReason.DISCARDEDyesnoEntity.discard, every despawn, the client replacing a network id
Entity.RemovalReason.UNLOADED_TO_CHUNKnoyesthe unload above — the only reason that saves
Entity.RemovalReason.UNLOADED_WITH_PLAYERnonoa vehicle travelling inside a player’s own save data
Entity.RemovalReason.CHANGED_DIMENSIONnonoa portal, where the entity is rebuilt on the far side

Destroys means LevelCallback.onDestroyed fires — the scoreboard entry and the waypoint go. An unloading zombie keeps all of it, because Entity.RemovalReason.UNLOADED_TO_CHUNK does not destroy. The five are not a state machine: Entity.setRemoved writes the reason only if none is set, so the first one wins and a second call cannot change it — though the rest of Entity.setRemoved still runs, dropping passengers and firing EntityInLevelCallback.onRemove with the new reason. It drops its passengers unconditionally, but dismounts the entity from its own vehicle only when the reason destroys. The one link that survives a removal by design is an EntityReference held by somebody else — it keeps a UUID, upgrades to the object on first resolution, and falls back to the UUID when the target goes, which is how who last hurt me survives a chunk unload.

Where to look

NaturalSpawner.spawnCategoryForPosition · NaturalSpawner.SpawnState · LocalMobCapCalculator · ChunkMap.collectSpawningChunks · SpawnPlacements · EntitySpawnReason · Mob.finalizeSpawn · Mob.checkDespawn · MobCategory · ServerLevel.addFreshEntity · PersistentEntitySectionManager.updateChunkStatus · Visibility · EntityLookup · EntitySection · EntitySectionStorage · LevelCallback · EntityInLevelCallback · EntityTickList · ServerLevel.tickNonPassenger · Level.guardEntityTick · EntityStorage · Entity.RemovalReason · Entity.setRemoved · TransientEntitySectionManager

Before this page: authority, on which side is allowed to decide any of it. After it: synched entity data — what the ClientboundAddEntityPacket bundle above is carrying, and how it stays current.


Rules: names, never code · how the system works, not how the code reads · newest version only · every backticked name passes tools/verify_names.py.