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

Points of interest

Verified against Minecraft 26.3 · Part IV · A villager claims a bed.

A villager standing in the middle of a field at noon decides which bed is its own. It is not near it, it is not looking at it, and it will not walk to it for another six thousand ticks. AcquirePoi asked the index for the beds with a free ticket within 48 blocks, took the five nearest, asked PathNavigation for a path to all five at once, and the moment Path.canReach came back true it called PoiManager.take and decremented that bed’s ticket. Night has nothing to do with it. Hours later SleepInBed will finally put it in the bed and set AbstractBedBlock.OCCUPIED, and because both occupied variants of a bed head are in PoiTypes.BEDS and map to the same PoiTypes.HOME, that block change does not touch the record at all: the claim and the occupied flag speak in one direction only. Going to sleep tells the index nothing, and of the three behaviours that read the flag back, the only one that turns it into a change in the record can do just one thing with it — take a claim away. Neither is what a player would call ownership.

The cast

classwhat it decidesthread
PoiTypehow many tickets a kind of block hands out and how close is close enough — a record of PoiType.matchingStates, PoiType.maxTickets, PoiType.validRangestatic, built at bootstrap
PoiTypesthe catalogue, and the block-state → type map PoiTypes.TYPE_BY_STATE that every lookup goes throughstatic
PoiRecordone position’s type and its PoiRecord.freeTickets counterServer
PoiSectionthe records in one 16³ section, by section-relative position and by type, plus the Valid flag that decides whether the section is rebuilt on loadServer
PoiManagerthe index: a SectionStorage over the poi/ region files, the whole query family, and the village distance graphServer
ServerLevelthat a block change happened at all — ServerLevel.updatePOIOnBlockStateChange is the door every ordinary write goes throughServer, or the worldgen executor
AcquirePoiwhich POI a mob claims, and when to stop asking about one it cannot reachServer
ValidateNearbyPoi · SleepInBedwhether a remembered POI is still real, and what to do on arrivalServer

A ticket is a claim nothing enforces

A point of interest is a block state the game has decided is worth going to. There are twenty-one kinds, none data-pack-driven: PoiTypes.bootstrap registers them into BuiltInRegistries.POINT_OF_INTEREST_TYPE and files every one of their block states into PoiTypes.TYPE_BY_STATE, throwing at startup if two types claim one state. PoiTypes.forState and PoiTypes.hasPoi are the only questions anyone asks of that map.

A ticket is the claim. A PoiRecord starts with PoiType.maxTickets free and hands them out one at a time: PoiRecord.acquireTicket decrements, PoiRecord.releaseTicket increments, both refuse to run past their end of the range, and both mark the section dirty. PoiRecord.hasSpace asks whether any are left, PoiRecord.isOccupied whether any are gone — the second question being the one the village graph reads. Neither says anything about who: a record does not know its holder, a holder is not told when its record disappears, and no state on the block corresponds to a claim.

stateDiagram-v2
    [*] --> Free : block placed, PoiManager.add, PoiType.maxTickets free
    Free --> Held : PoiManager.take, then PoiRecord.acquireTicket
    Held --> Free : PoiManager.release, from one of four behaviours or the villager's end
    Held --> Gone : the block changes, so PoiManager.remove
    Free --> Gone : the block changes, so PoiManager.remove
    Gone --> [*]
    note right of Held : occupied, so its section is a village centre if the type is in PoiTypeTags.VILLAGE
    note right of Gone : nothing is released, and the claimant is not told

The whole life of one ticket, and the thing to look for is what is missing. The only way back to Free is a villager letting go, through a behaviour in its brain or its end, and the two arrows into Gone bypass the claim entirely — the block changed, the record went, and the note says what nobody hears about it.

The asymmetry in that figure is deliberate on the release side and merely survivable on the removal side. PoiManager.release throws when the section is not there and PoiSection.release throws when the record is not, which is why four of the five that release a ticket check PoiManager.getType or PoiManager.exists first. Those five are ValidateNearbyPoi, Villager.releaseAllPois (through Villager.releasePoi, which checks the type and then tests it against Villager.POI_MEMORIES before it dares), SetWalkTargetFromBlockMemory, GoToPotentialJobSite and VillagerMakeLove. The last checks nothing, and gets away with it because the position it releases is one PoiManager.take handed back a moment earlier. PoiSection.remove on a missing record only logs an error, so the removal path is allowed to be wrong and the release path is not.

Erasing a memory is not a release, which is what makes the job-site behaviours noisy and harmless: PoiCompetitorScan awards a contested site to whichever villager has the higher Villager.getVillagerXp and makes the loser erase MemoryModuleType.JOB_SITE, YieldJobSite has an unemployed villager hand its MemoryModuleType.POTENTIAL_JOB_SITE to a neighbour whose profession the site belongs to, and AssignProfessionFromJobSite promotes the potential site to the real one. None of those three touches a ticket — though GoToPotentialJobSite does, GoToPotentialJobSite.stop giving back the ticket AcquirePoi took on the potential site.

The catalogue

typesthe blockPoiType.maxTicketsPoiType.validRange
PoiTypes.ARMORER PoiTypes.BUTCHER PoiTypes.CARTOGRAPHER PoiTypes.CLERIC PoiTypes.FARMER PoiTypes.FISHERMAN PoiTypes.FLETCHER PoiTypes.LEATHERWORKER PoiTypes.LIBRARIAN PoiTypes.MASON PoiTypes.SHEPHERD PoiTypes.TOOLSMITH PoiTypes.WEAPONSMITHone work block each, in the same order — blast furnace, smoker, cartography table, brewing stand, composter, barrel, fletching table, all four cauldrons, lectern, stonecutter, loom, smithing table, grindstone11
PoiTypes.HOMEthe bed’s head half only — PoiTypes.BEDS filters AbstractBedBlock.PART to BedPart.HEAD11
PoiTypes.MEETINGthe bell326
PoiTypes.BEEHIVE PoiTypes.BEE_NESThive and nest01
PoiTypes.NETHER_PORTALthe portal block01
PoiTypes.LODESTONEthe lodestone01
PoiTypes.LIGHTNING_RODthe rod, every facing01
PoiTypes.TEST_INSTANCEthe test instance block01

Six of the twenty-one types are locatable but unclaimable. PoiRecord.hasSpace is false for them forever, so nothing can PoiManager.take one, and PoiRecord.isOccupied — which asks whether the free count has moved off PoiType.maxTickets — is false forever too. Not that it would matter: none of the six is in PoiTypeTags.VILLAGE, so none was ever a candidate for a village centre. They are indexed so something can find one fast: the bee’s hive search asks for PoiTypeTags.BEE_HOME within 20 blocks and then filters through Bee.doesHiveHaveSpace, which asks the BeehiveBlockEntity whether it is full. The index answers where and something else answers whether. Three tags cut across the catalogue (tags): PoiTypeTags.ACQUIRABLE_JOB_SITE for the thirteen professions, PoiTypeTags.BEE_HOME for the two hives, and PoiTypeTags.VILLAGE for those thirteen plus PoiTypes.HOME and PoiTypes.MEETING — fifteen types whose occupied records are what a village is.

Where the index lives, and how it repairs itself

PoiManager extends SectionStorage, so it is the third of the three region stores a dimension keeps (four folders, three of them the same shape), and the unit of storage is a chunk section and the unit of file a region: ChunkMap builds it on the dimension’s poi/ folder, PoiManager‘s own constructor naming DataFixTypes.POI_CHUNK, and PoiSection.Packed is the on-disk shape — a Valid boolean and a list of Records, each a position, a type and a free_tickets count. Tickets survive a restart, and so do the villagers’ memories of them, and nothing on load reconciles the two.

ChunkMap.tick runs PoiManager.tick under the profiler’s poi, which writes dirty chunks for as long as the tick has time and then settles the village graph (the level tick owns the budget). Reads are the interesting half. The query family is a dozen shapes of the same walk — PoiManager.getInSquare over a chunk range, PoiManager.getInRange narrowing it to a sphere, and PoiManager.findClosest, PoiManager.getRandom, PoiManager.getCountInRange, PoiManager.exists and PoiManager.getType above them — and every one bottoms out in SectionStorage.getOrLoad, where a section whose prefetch has not finished is read from disk synchronously, on the Server thread, blocking. That is why ChunkMap.scheduleChunkLoad fires SectionStorage.prefetch beside the chunk’s own parse and joins the two before either is used.

The repair runs on every chunk read. SerializableChunkData.read calls PoiManager.checkConsistencyWithBlocks once per section with block data. If the section is in storage, PoiSection.refresh rebuilds it — but only if its Valid flag is false, and the rebuild reuses the existing PoiRecord objects for positions that still have a POI, so a repair does not reset anybody’s tickets. If it is not in storage, one is created and scanned. Both scans are short-circuited by PoiManager.mayHavePoi, which asks LevelChunkSection.maybeHas whether the palette holds any state PoiTypes.hasPoi recognises (the palette and the ladder it climbs), so a section of plain stone is dismissed without one block read. The Valid flag’s codec defaults to false: anything not explicitly written as validated gets rescanned.

A record appears when a block changes, sometimes a task late

Level.setBlock calls Level.updatePOIOnBlockStateChange last, after the neighbour updates, and on a ServerLevel that override compares PoiTypes.forState of the old and the new state. Equal types mean nothing happens — exactly the bed case, since a bed head that gains AbstractBedBlock.OCCUPIED is still PoiTypes.HOME. Different types mean a PoiManager.remove for the old and a PoiManager.add for the new, each wrapped in a BlockableEventLoop.execute on the server.

That wrapper exists because WorldGenRegion.setBlock calls the same hook from the worldgen executor and the index is Server-thread-only. What it does not do is defer the ordinary case: MinecraftServer.scheduleExecutables is false when the caller is already the Server thread and not inside a task the loop is running (ReentrantBlockableEventLoop.runningTask), so a block placed by a player, a command or a mob during the tick body gets its record synchronously. Deferral is the worldgen and nested-task case — there the record appears a task later than the block it describes, and a read in between gets the old answer.

Noon, and a bed forty-eight blocks away

A bed is placed, a villager claims it, and thousands of ticks later it sleeps in it. The index is written when the bed goes down and asked, and a ticket taken, when the villager claims it — not the time you would guess — and at night asked only whether the record is still there.

sequenceDiagram
    participant SL as ServerLevel
    participant PM as PoiManager
    participant Brain as Brain
    participant AP as AcquirePoi
    participant PN as PathNavigation
    participant SIB as SleepInBed

    Note over SL,PM: any tick, Server thread
    SL->>SL: setBlock puts the bed head down, and the two states differ
    SL->>PM: add — a PoiRecord with one free ticket, the section dirty
    Note over Brain,SIB: Activity.CORE, any hour of the day, HOME absent
    Brain->>AP: at priority 10, and this evaluation is due
    AP->>PM: findAllClosestFirstWithType HOME, HAS_SPACE, 48 blocks
    PM-->>AP: the nearest five, past the retry cache
    AP->>AP: VillagerGoal<br/>Packages.<br/>validateBedPoi<br/>re-reads all five
    AP->>PN: createPath to all five at once, reach range 1
    PN-->>AP: a Path whose canReach is true, getTarget is one bed
    AP->>PM: take at that position, and one free ticket becomes zero
    AP->>Brain: MemoryModuleType.HOME set to a GlobalPos
    AP->>SL: broadcastEntityEvent 14
    Note over Brain,SIB: thousands of ticks later, tick 12000, Activity.REST
    Brain->>Brain: SetWalkTarget<br/>FromBlockMemory<br/>writes WALK_TARGET
    Brain->>Brain: ValidateNearbyPoi, within 16 blocks: is the record still HOME
    Brain->>SIB: within 2 blocks, and the bed is not OCCUPIED
    SIB->>SL: setBlock, AbstractBedBlock.OCCUPIED true
    SL->>SL: the state is HOME either way, so nothing is queued
    Note over Brain,SIB: morning, REST leaves the brain, SleepInBed.stop or WakeUp clears the flag, the ticket stays

A bed placed and a bed slept in: the index is written at the top, asked, and a ticket taken, at the claim in the middle, and at night asked only whether the record still exists. The last self-message is the page’s hook drawn as a non-event — sleeping wrote AbstractBedBlock.OCCUPIED straight to the block, and because the before and after are both PoiTypes.HOME the manager was never asked about it.

The scan is cheap and the path is not. PoiManager.findAllClosestFirstWithType turns a 48-block radius into a chunk radius of four, walks every section of those chunks, filters by PoiManager.Occupancy.HAS_SPACE and sorts by distance; AcquirePoi takes the first five and only then runs VillagerGoalPackages.validateBedPoi, which re-reads each block to confirm it is in BlockTags.VILLAGERS_CAN_SLEEP_ON_BED and not already AbstractBedBlock.OCCUPIED. Then AcquirePoi.findPathToPois hands all five positions to PathNavigation.createPath as one target set, at the reach range from PoiType.validRange — one, for a bed. A villager’s constructor raises PathNavigation.setRequiredPathLength to 48 so that this search can span the scan range (pathfinding owns that budget). Beds that failed before are held off by AcquirePoi.JitteredLinearRetry, whose delay is cumulative: each attempt adds another 40 to 79 ticks to that position’s own counter, capped at AcquirePoi.JitteredLinearRetry.MAX_RETRY_PATHFINDING_INTERVAL, 400. A bed behind a wall is checked ever more rarely until the gap reaches the cap, twenty seconds, and then the marker, untouched that long, is dropped, so the next check starts the saw again from the beginning. A successful claim clears the whole cache.

The claim itself is five statements. PoiManager.take — alone among the radius searches in having no PoiManager.Occupancy parameter, because it always means HAS_SPACE — is called with radius 1 around the path’s target and a filter accepting only that exact position, and calls PoiRecord.acquireTicket on what it finds. Then MemoryModuleType.HOME is set to a GlobalPos, then ServerLevel.broadcastEntityEvent sends event 14, EntityEvent.VILLAGER_HAPPY — the green particle burst, and the only thing an ordinary client learns of any of this. The last two statements clear the retry cache and tell the debug channel. The memory is set inside PoiManager.getType’s ifPresent, not inside PoiManager.take’s — the take’s result is never consulted.

After the claim: the night shift

Brain.updateActivityFromSchedule, run from UpdateActivityFromSchedule at priority 99 and only when more than twenty ticks have passed since it last did anything, reads the villager’s schedule attribute — EnvironmentAttributes.VILLAGER_ACTIVITY for an adult, EnvironmentAttributes.BABY_VILLAGER_ACTIVITY for a child — at the villager’s own position, and Timelines.VILLAGER_SCHEDULE puts the Activity.REST keyframe at tick 12000 of a 24000-tick period (environment attributes owns the mechanism, goals and brains the pull that reads it; the old Schedule class is gone). Only then does the bed half of the brain exist at all: VillagerGoalPackages.getRestPackage is where SetWalkTargetFromBlockMemory, ValidateNearbyPoi for PoiTypes.HOME and SleepInBed live, while the core package validates the job site and not the bed. So between dawn and dusk a villager whose bed was mined keeps pointing at a position with no record, and nothing tells it otherwise.

What the three of them do to the record

At night the three run in priority order, and only two of them ever touch a ticket. SetWalkTargetFromBlockMemory at priority 2 is the walking half — where it sends a villager and how it picks an intermediate target belong to goals and brains; what belongs here is its one effect on the index, which is that it gives up, releasing the ticket and erasing the memory, once MemoryModuleType.CANT_REACH_WALK_TARGET_SINCE has stood for more than 1200 ticks, the memory points into another dimension, or, with the bed more than 150 blocks off, a thousand tries find no step toward it. ValidateNearbyPoi at priority 3 does nothing at all unless the bed is within 16 blocks and in this dimension: then it erases the memory if PoiManager.exists no longer agrees on the type, and if the bed is AbstractBedBlock.OCCUPIED while this villager is not asleep — asleep anywhere, not asleep here — it erases the memory. Whether it also releases the ticket turns on a second question, asked of the world rather than of the block: is a Villager actually sleeping inside that block’s box? If one is, the memory goes and the ticket stays, the sleeper being presumed to hold it. If none is, the ticket goes too. The two can disagree — a player asleep in the bed sets the same flag — and that disagreement is the whole reason the second question exists. SleepInBed, also priority 3, is the one that touches no ticket at all. It needs the villager within 2 blocks, the bed unoccupied and SleepInBed.COOLDOWN_AFTER_BEING_WOKEN ticks since MemoryModuleType.LAST_WOKEN; it calls LivingEntity.startSleeping, which is what actually sets the flag. That is the second of the three reads of AbstractBedBlock.OCCUPIED — a gate on entering the bed, not a change to the record — and the third is VillagerGoalPackages.validateBedPoi, the filter AcquirePoi runs over its best five.

Morning ends it twice over: when Activity.REST goes inactive, SleepInBed.stop wakes the villager as the behaviour ends, and WakeUp, at priority 0 in the core package, calls LivingEntity.stopSleeping on any villager still asleep outside it. Either way the flag clears and the ticket does not.

What makes a village

PoiManager.DistanceTracker is a SectionTracker — the same DynamicGraphMinFixedPoint flood the ticket system’s two graphs use (two graphs, one store), and the only one of the five outside server/level — over chunk sections instead of chunks. Its sources are the sections where PoiManager.isVillageCenter holds: at least one record whose type is in PoiTypeTags.VILLAGE and whose PoiManager.Occupancy is IS_OCCUPIED. They sit at level 0 and the flood runs out to PoiManager.MAX_VILLAGE_DISTANCE, six sections, past which the level is simply absent from the map.

A village is made of the sections in memory only. Alone in the query family, PoiManager.isVillageCenter reads through SectionStorage.get rather than SectionStorage.getOrLoad — the getter that answers null for a section not in memory instead of pulling it off the disk — and it treats that null as not a centre. So the graph is not a picture of what is in the save; it is a picture of what has been read into memory this run, which nothing evicts. That is deliberate: the flood is settled every tick from PoiManager.tick, and a version of it that could touch the disk would turn the village graph into an IO source in the middle of the level tick.

An unclaimed bed makes no village. A hundred empty beds are a hundred records and zero sources; one villager taking one ticket lights the section up. PoiManager.setDirty and PoiManager.onSectionLoad re-seed the source, PoiManager.tick settles the flood every tick, and PoiManager.sectionsToVillage settles it again before answering. ServerLevel.isVillage is that distance being one section or less; ServerLevel.isCloseToVillage takes the distance as an argument and refuses anything past six.

That boolean is load-bearing far outside this system. BadOmenMobEffect starts a raid only where ServerLevel.isVillage is true, VillageSiege needs one to put zombies in, PatrolSpawner refuses a patrol when its chosen player stands within two sections of one and CatSpawner insists on being within two. Raid re-checks it as the raid runs, and Raids sets the raid’s centre to the average position of the occupied PoiTypeTags.VILLAGE records within 64 blocks.

Other readers of the index

whowhat it asks forradiusPoiManager.Occupancy
PortalForcer.findClosestPortalPositionPoiTypes.NETHER_PORTAL, after PoiManager.ensureLoadedAndValid drags the chunks in16 going to the Nether, 128 coming backANY
BeePoiTypeTags.BEE_HOME, then Bee.doesHiveHaveSpace for the real occupancy20ANY
ServerLevel.findLightningRodPoiTypes.LIGHTNING_ROD standing at the surface height128ANY
LodestoneTrackerone position — is PoiTypes.LODESTONE still there——
LocateCommandPoiManager.findClosestWithType for a type or a tag256ANY
Raidsthe records it averages into a raid’s centre64IS_OCCUPIED
CatSpawnermore than four claimed PoiTypes.HOME nearby — asked only after ServerLevel.isCloseToVillage at two sections has already said yes48IS_OCCUPIED
WanderingTraderSpawnera PoiTypes.MEETING near a player, to arrive at48ANY
NearestBedSensorPoiTypes.HOME for MemoryModuleType.NEAREST_BED, babies only, no ticket taken48ANY

PoiManager.ensureLoadedAndValid in the first row is the only caller that forces the chunks themselves to load, where every other query reads the POI file alone: for any nearby section missing or invalid it pulls the chunk in at ChunkStatus.EMPTY, once per chunk per server run since PoiManager.loadedChunks never forgets. The index is on the debug channel too — LevelDebugSynchronizers.registerPoi, LevelDebugSynchronizers.updatePoi and LevelDebugSynchronizers.dropPoi feed DebugSubscriptions.POIS and DebugSubscriptions.VILLAGE_SECTIONS.

Questions players ask

Why is a villager sleeping in the bed I built for someone else? Because the claim is a number in a file and the bed is a block, and neither knows the other. PoiManager.take decremented a counter 48 blocks away hours before anyone walked anywhere, and SleepInBed only ever checks that the bed is not currently occupied — never who holds the ticket.

Why do villagers stop breeding when I take a bed away? VillagerMakeLove.tryToGiveBirth calls VillagerMakeLove.takeVacantBed first — a PoiManager.take for PoiTypes.HOME within 48 blocks, filtered by reachability. No free ticket, no baby, and the pair get entity event 13, EntityEvent.VILLAGER_ANGRY, instead. If the birth then fails the ticket is released; if it succeeds, VillagerMakeLove.giveBedToChild writes the baby’s MemoryModuleType.HOME directly, with no AcquirePoi involved. This is the raw PoiManager.take, which finds the first reachable match in section order rather than the nearest, so the baby’s bed need not be the closest.

Why does the same bed get claimed twice after I break and replace it? Breaking it runs PoiManager.remove and the record ceases to exist with its ticket count — no release, no notification. The villager keeps its GlobalPos until ValidateNearbyPoi next runs, which needs Activity.REST active and the villager within 16 blocks, or SetWalkTargetFromBlockMemory gives it up. Replace the bed first and there is a brand-new record with a full ticket: another villager can claim it, while the one whose memory already points there keeps pointing, so two villagers hold one bed.

Where to look

The record and the three things that happen to one: PoiRecord.acquireTicket · PoiManager.add · PoiManager.take · PoiManager.release. Where they live and how they are repaired: PoiManager.checkConsistencyWithBlocks · PoiSection.refresh · ServerLevel.updatePOIOnBlockStateChange. The claim, from the brain’s side: AcquirePoi.create · ValidateNearbyPoi.create. And the flood that turns claimed records into a village: PoiManager.DistanceTracker · ServerLevel.isVillage.

The other index in this corner of the tree — the fire-and-forget broadcast sculk sensors listen to — is game events and vibrations. The brain belongs to goals and brains, death and conversion to the entity lifecycle, the bed block to blocks and states.


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