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
| class | what it decides | thread |
|---|---|---|
PoiType | how many tickets a kind of block hands out and how close is close enough — a record of PoiType.matchingStates, PoiType.maxTickets, PoiType.validRange | static, built at bootstrap |
PoiTypes | the catalogue, and the block-state → type map PoiTypes.TYPE_BY_STATE that every lookup goes through | static |
PoiRecord | one position’s type and its PoiRecord.freeTickets counter | Server |
PoiSection | the records in one 16³ section, by section-relative position and by type, plus the Valid flag that decides whether the section is rebuilt on load | Server |
PoiManager | the index: a SectionStorage over the poi/ region files, the whole query family, and the village distance graph | Server |
ServerLevel | that a block change happened at all — ServerLevel.updatePOIOnBlockStateChange is the door every ordinary write goes through | Server, or the worldgen executor |
AcquirePoi | which POI a mob claims, and when to stop asking about one it cannot reach | Server |
ValidateNearbyPoi · SleepInBed | whether a remembered POI is still real, and what to do on arrival | Server |
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
| types | the block | PoiType.maxTickets | PoiType.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.WEAPONSMITH | one 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, grindstone | 1 | 1 |
PoiTypes.HOME | the bed’s head half only — PoiTypes.BEDS filters AbstractBedBlock.PART to BedPart.HEAD | 1 | 1 |
PoiTypes.MEETING | the bell | 32 | 6 |
PoiTypes.BEEHIVE PoiTypes.BEE_NEST | hive and nest | 0 | 1 |
PoiTypes.NETHER_PORTAL | the portal block | 0 | 1 |
PoiTypes.LODESTONE | the lodestone | 0 | 1 |
PoiTypes.LIGHTNING_ROD | the rod, every facing | 0 | 1 |
PoiTypes.TEST_INSTANCE | the test instance block | 0 | 1 |
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
| who | what it asks for | radius | PoiManager.Occupancy |
|---|---|---|---|
PortalForcer.findClosestPortalPosition | PoiTypes.NETHER_PORTAL, after PoiManager.ensureLoadedAndValid drags the chunks in | 16 going to the Nether, 128 coming back | ANY |
Bee | PoiTypeTags.BEE_HOME, then Bee.doesHiveHaveSpace for the real occupancy | 20 | ANY |
ServerLevel.findLightningRod | PoiTypes.LIGHTNING_ROD standing at the surface height | 128 | ANY |
LodestoneTracker | one position — is PoiTypes.LODESTONE still there | — | — |
LocateCommand | PoiManager.findClosestWithType for a type or a tag | 256 | ANY |
Raids | the records it averages into a raid’s centre | 64 | IS_OCCUPIED |
CatSpawner | more than four claimed PoiTypes.HOME nearby — asked only after ServerLevel.isCloseToVillage at two sections has already said yes | 48 | IS_OCCUPIED |
WanderingTraderSpawner | a PoiTypes.MEETING near a player, to arrive at | 48 | ANY |
NearestBedSensor | PoiTypes.HOME for MemoryModuleType.NEAREST_BED, babies only, no ticket taken | 48 | ANY |
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.