Skip to content

Entity and tick re-architecture: one entity model, one tick pipeline ​

Status: in progress. The decisions were taken on 2026-11-10 and this plan was written the same day. Counts in this page are from that day. Slices 0 to 2 have landed on the program branch (t3/subagent-architecture-review, pull request #2948), and slices 3 to 5 were in progress when work moved to another machine on 2026-11-10. The handoff says where each package stands and how to pick it up.

The server runs two simulations of the same world and reconciles them every tick. Every data-driven mob lives once in EntityRuntime and a second time as a legacy body in the world's entity map, and health crosses between the two by comparison. Two entity indexes answer the same question under opposite rules. A tick awaits at 233 points, so a packet handler can change a player in the middle of it. This plan replaces all of that with one entity store, one index, a synchronous tick, and replication that reads a change log.

The decisions are in ADRs 0006 to 0018. This page holds what does not fit an ADR: what the review changed, the order of the work, how each slice is judged, and what is still unknown.

Priorities ​

  1. The server feels like Minecraft and mostly is Minecraft, and it keeps up with Mojang's releases. That is why behavior comes from data and not from reimplementation.
  2. Performance, a close second.
  3. The program finishes. No slice ends with mobs on one model and players on another, and the slices that create a temporary mix are planned as one commitment.

Decisions ​

  • ADR 0006: a replay gives identical digests at any host speed.
  • ADR 0007: one entity index, and a write is visible to the next query.
  • ADR 0008: one entity model, with players and items in the same store.
  • ADR 0009: external inputs enter once per tick and are recorded, and a handler's lane decides whether its packet is an input.
  • ADR 0010: no barrel files, and explicit package exports.
  • ADR 0011: a world exchanges only plain data with the outside.
  • ADR 0012: the change log is the history, complete and invertible.
  • ADR 0013: vetoable actions run as intent, guard, compute and commit.
  • ADR 0014: handlers share one shape, and input-lane handlers run in the input step.
  • ADR 0015: a tick is a fixed sequence of synchronous phases.
  • ADR 0016: replication reads the change log after the tick and keeps a replica.
  • ADR 0017: one lifecycle event bus.
  • ADR 0018: a save is a snapshot of one tick boundary.

What the review changed ​

The first version of this plan left ten gaps. Each one is closed below, with the evidence that closed it. Seven of them changed an ADR.

Replication needs more than the current tick's log ​

A player who walks into range of an entity that has not changed needs its full state, and the current tick's log has no entry for it. Today spawnTo reads position, rotation, metadata, attributes, equipment and effects from the live entity object and encodes a spawn packet for each viewer. Nothing caches replicated state.

Replication now keeps a replica, built only from the log. A membership entry carries an entity's full replicated state, and later entries update it. A first sight is answered from the replica and encoded once for each codec group. Replication still reads nothing from the simulation except the log, which keeps it movable to a worker. The cost is a second copy of the client-visible fields of each loaded entity, counted as replica entries and bytes. ADR 0016 records this.

Invertibility needed a scope and a use ​

ADR 0012 priced the before-values at three numbers for a moving entity, and authoritative state also includes blocks, inventories and block entities. Nothing in the program read a before-value.

Three things close this. First, the log has three entry kinds with stated inverses, and a chunk that enters the world is named in the log and not carried by it, because it arrived as an input. Second, a unit of work that throws is undone by inverting its span, which gives invertibility a use and a test inside the program and also closes the failure gap below. Third, this program keeps a tick's log only until replication and the save cut have read it. The before-values then cost memory for one tick and nothing on disk. Slice 2 reports deltas and bytes per tick on the replay scenarios as a non-gating count, so the retention work that comes later starts from real figures.

The tick was audited for awaits ​

The handlers had been audited and the simulation had not. An audit of everything reachable from World.update found 233 awaits and about 20 promises the tick starts without awaiting.

ClassAwaitsWhat removes themSlice
Packet sends77Sends become entries in the change log7
Functions that are async only to pass a wait on120They become synchronous once the waits below them are gone3 to 9
Chunk or block reads that may load a chunk21Loaded-chunk accessors, chunk tickets and waiting states3, 5, 9
Disk reads and writes4Chunk loads as inputs, saves under ADR 00188, 9
Awaited events3Guards under ADR 00134, 9
Other waits8Worlds built outside ticks, completion results as inputs9

Most of the 233 are mechanical. The simulation never reads the result of a send, and WorldSystems.tick, RaidManager.tick, cooking stations, spawners, EntityRuntime.tick and the block-behaviors, engine-systems, lighting, spawning, entities, entity-goals and entity-brains packages contain no asynchronous code today.

Three sites are hard, and each needs a mechanism this plan now names.

  • Portal transfer runs inside an entity's update (Entity.ts:526). It can build a dimension, generate chunks, raise a cancellable event and save player data. It becomes the message of ADR 0011: the source world commits the departure in its tick, and the destination admits the arrival as an input once its chunks are ready.
  • Respawn search runs inside onDeath, which runs inside damage (Player.ts:988). It is why every caller of damage is asynchronous, and the runtime starts damage without awaiting it. Death commits in the tick. The respawn point becomes a request to a world service, and its answer returns as an input.
  • The dragon fight loads its arena, gateway and pillar chunks inline (DragonFight.ts:390, :512, :565). It takes chunk tickets and gets explicit waiting states.

Unloaded chunks are already handled the right way by most of the simulation. Block updates skip them, fluids and scheduled ticks read air and have their writes refused, and explosions and data-driven mobs read only loaded chunks. The code that loads a chunk from inside a tick is the legacy item entity, the player breath check, the dragon fight, portal travel and World.setBlockRuntimeId.

Each slice removes the awaits in the code it rewrites, and the count is reported from slice 1 on. The test that a tick completes without yielding can only pass at slice 9.

The temporary mix was too long ​

The first version kept a transitional adapter over legacy entities from slice 2 to slice 8 and deleted the legacy classes at the end. The legacy classes now go in slice 5, when players move, so the adapter lives for slices 2 to 5. Those four slices are one commitment. Slice 2 does not start until the work through slice 5 is planned.

Slice 5 is where the program is most likely to stall, because Player carries the entity, the connection, the saved record and server-wide chat and permissions. A spike before slice 2 puts one player-shaped entity into a prototype store, with an inventory, a network session that the entity references and a save and load round trip. The spike is thrown away. If it shows that a player cannot be expressed in the shape of ADR 0008, that ADR is reopened before any store code lands.

One input step would have throttled chunk streaming ​

Of the 72 handler classes, 19 write authoritative state, 15 read it without writing, and 38 touch only their own connection. A sub-chunk request is answered in handler context today. Under one input step for every packet it would wait for the next tick and slow down with the tick rate.

ADR 0009 now defines three lanes. The connection lane and the read lane run when the packet arrives, and only input-lane packets are inputs. Four of the 15 readers relay something other players see (ActorEvent, Animate, Emote, LevelSoundEvent), so they belong in the input lane, where the relay becomes a log entry. The read lane is for handlers that reply only to the sender. LevelChunk delivery already runs from its own scheduler outside the tick and stays there.

The read lane narrows the outbound digest in ADR 0006. The digest covers what replication produces from the log, counted toward the tick that produced it. Read-lane replies and chunk streaming are outside it, because no tick decides when they leave. Today's recorder stamps a packet with the tick in which the transport framed it, which slice 7 changes.

Guards have to replace parameters ​

The first version of ADR 0013 said a scripting before-hook could only pass or veto. In @minecraft/server 2.11.0, ten of the seventeen stable before-events let a script rewrite a parameter, such as the damage of EntityHurtBeforeEvent or the block list of ExplosionBeforeEvent. Bedrock add-ons are the primary way to extend the server, so a pass-or-veto rule would have ruled out a faithful host.

A guard now returns pass, veto or replacement values for the parameters its pipeline declares replaceable, and still writes no state. That matches the documented restricted execution of before-event callbacks. ADR 0017 changed for the same reason. Bedrock runs a system.run callback at the end of the same tick, so the ADR no longer places it in the next input step and leaves the point to the Script API host design.

Saves had no rule ​

A save pass mixes ticks today. Autosave starts in the middle of a tick, captures every player at that instant and then serializes chunks eight at a time across later ticks. A crash can leave an item in a saved inventory and also in a chest saved later. An entity that changes chunk during the pass can be written twice or not at all.

ADR 0018 says a pass writes the state of one tick boundary and commits it as one atomic write. It was proposed and not accepted, because the commit was a synchronous LevelDB append on the server thread and its size was unknown. B4 measured the bytes in a pass (the measurement), and the ADR was accepted on 2026-11-12 with the changes the measurement asked for: the cut belongs to the level, fresh chunks are written outside it, copy-on-write works per sub chunk, writes outside a pass join the next commit, and the commit leaves the server thread.

A failed update left partial writes ​

ADR 0015 skipped an entity that throws and assumed the store stays consistent. An update that throws halfway has already written. The store now undoes the span of the unit that threw before it skips the entity, as described under invertibility above.

Slice 1 promised too much, and proxies had no slice ​

Slice 1 was named "trustworthy replay", and replay is not fully trustworthy until the tick is synchronous. The slice list below says what replay guarantees after each step, and the target table names the slice at which each proxy starts to gate.

Slice order ​

Every slice lands on master, and a slice deletes what it replaces. The one exception is the adapter over legacy entities, which slice 2 adds and slice 5 removes.

  1. Barrel ban. Biome's noBarrelFile and noReExportAll with a baseline that only shrinks. No code changes. This slice is independent of the others and can land at any time.
  2. Tick boundaries. Every handler declares a lane. The dispatcher holds input-lane packets, and the ticker applies them at the start of the next tick in arrival order, which is what the replay harness already does when it injects. Capture stamps a packet with the tick that applied it. Chunk completions are recorded as inputs. Counts replace the 20 ms and 25 ms budgets. Worlds tick one after another in a fixed order, and a failing world does not skip the next. Handlers are still asynchronous here, so a tick can still yield, but no handler runs in the middle of one. After this slice, live play and its replay apply the same inputs at the same ticks.
  3. The entity store. The store, setters that append entries with before-values, spans and their inverses, the one index, and the adapter over legacy entities. The player spike comes first.
  4. Items and the eye of ender move into the store with built-in definitions. The item ground probe stops loading chunks.
  5. Staged damage and death. The store owns damage and death under ADR 0013, with replaceable parameters. The mirror and the health comparison are deleted. damage becomes synchronous, which removes the promises the runtime starts without awaiting. Respawn search becomes a world service request.
  6. Players move into the store, split into components, a network session and services for chat, permissions and persistence. Entity, GenericEntity, Human, the Player class and the adapter are deleted. No legacy entity remains after this slice.
  7. Handlers. The shared shape, a narrow context and synchronous input-lane handlers. The client package adopts the shape.
  8. Replication reads the log. Block and block entity writes go through logged setters, so the log is complete. Replication builds its replica, computes each audience once and encodes once for each codec group. Sends leave the simulation, which removes the largest class of awaits. Outbound packets count toward the tick that produced them.
  9. Saves at a tick boundary, under ADR 0018.
  10. The synchronous tick. Dimension travel as messages, chunk tickets and waiting states for the dragon fight, worlds built outside ticks, and tick functions admitted as inputs. After this slice a tick completes without yielding, and ADR 0006 holds in full.
  11. One lifecycle event bus.
  12. Explicit exports. Prismarine's barrels and its "./*" export are replaced.

Players come before replication so the log is complete when replication reads it. Handlers come before replication so they have somewhere to put their output. The cost of that order is that the encode-once change lands at slice 7.

Progress ​

As of 2026-11-11. The rows through C4f were checked at program head 7265972882, rebased on master 878604e167, where every CI check passed, Client E2E included. Each C4h step records its own checks below.

PackageState
A1 barrel ban, A2 count budgets and world order, A3 handler lanes, A4 change log module, A5 player spikeLanded
B1 input admission, with its two follow-up fixes (a held input blocked its connection; a stale move undid a teleport)Landed
B2 chunk completions as recorded inputs, with the follow-up fix for the tick that stalled after the dragon diedLanded
B3a one live player per account (#2951) and the persistence serviceLanded
B3b chat fan-out and respawn search as servicesLanded
B4 save pass measurement, B5 re-keyed mapsLanded
C1a component state on the instance, C1b one entity index, C1c the entity store with logged setters, spans and undoLanded
Replay determinism fixes (settle gaps, wall-clock chunk budget in the harness)Landed
C2 items and the eye of ender in the storeLanded
C3 staged damage and death, the mirror deletedLanded. Damage, death and client display are the store's; the legacy bodies remained for non-network callers until C3b
C3b the legacy mob bodies deleted, which meets slice 4's "no mirror class" targetLanded, see below
C4c player components with logged setters, and an inventory that logs one entry per slotLanded
C4b the player overlay, client-owned movement and a death that keeps the entityLanded. The store's rules are ready for a store player; the live player is still legacy, see below
C4e player persistence from the store entity, the saved record unchangedLanded, see below
C4g store players shown to clients through the current replicatorLanded. The store path is built and tested; the live player is still legacy, see below
C4d player systems: hunger, breath, falls, meals and mining, reading loaded chunks onlyLanded. The systems run over store players; the live player calls the same rules, see below
C4f the session holds the player's entity id, and the conversions split into four groupsLanded with its four groups, F1 to F4, by program head 7265972882. F3 and F4 left their notes in commit messages, which the C4h plan below folds in
C4h live players in the store, and the legacy classes deletedDone with H-S0 to H-D. No legacy entity remains, which meets slice 5's target, and noLegacyEntity.test.ts gates it; see below
Slice 5, players in the storeDone with C4b to C4h
D1 the shared handler shape, the narrow context and the proxy testLanded on the program branch, with four handlers converted; see the D1 notes under phase D
D3 block and block entity writes through logged settersLanded on the program branch, see the D3 notes under phase D
D2 the remaining handlers on the context, with its closeLanded on the program branch, with D2a to D2g and the close; see the D2 notes under phase D
D4 replication reads the logLanded on the program branch with D4a to D4f; see the D4 section under phase D
Slice 7, replication reads the logDone with D3 and D4a to D4f. Its three targets gate; see the D4f notes
E2 dimension travel as messages, E3 chunk ticketsLanded on the program branch; see the phase E notes
E4 tick functions as inputs, and the synchronous tickLanded on the program branch with E4a to E4f. Slice 9's target gates in SynchronousTick.test.ts; see the E4 notes
F1 one lifecycle event busLanded on the program branch. Slice 10 is done; see the F1 notes under phase F
F2 explicit exports for PrismarineLanded on the program branch. Slice 11 is done, and noBarrel.test.ts gates its target; see the F2 notes under phase F
E1 saves at a tick boundaryE1a, E1b, E1c and E1e landed on the program branch. E1d is left and follows the merge as #3060, because it needs a save format decision, so slice 8 is not whole. See the E1 notes under phase E

Phases and work packages ​

A slice is too large to hand to one person or agent, and several slices have parts that do not depend on each other. The phases below cut the slices into work packages. Packages in one phase run side by side, each in its own worktree, and a phase starts when the packages it depends on have landed. Each package commits in small steps and passes the gate below before it is rebased onto the program branch.

Phase A: foundations ​

Nothing here depends on another package, and no two packages edit the same files.

PackageSliceWorkMain files
A10Ban barrels with a baseline that only shrinksbiome.json, scripts/
A21Counts replace the 20 ms and 25 ms budgets. Worlds tick one after another in a fixed order, and a failing world does not skip the nextWorld.ts, WorldSystems.ts, modules/ticker.ts
A31Every handler declares a lane, and a test checks the registry. No handler changes when it runs yetnetwork/handler/, PacketHandlerRegistry.ts
A42The change log as a module of its own: the three entry kinds, spans, inverses and the one-tick lifetime, with nothing wired to itnew module beside world/
A52The player spike. It is thrown away, and its findings are kept as a research notedocs/research/

Phase B: admission and preparation ​

PackageSliceDepends onWork
B11A2, A3The dispatcher holds input-lane packets and the ticker applies them at tick start. Capture stamps the applying tick. The replay test compares two runs tick by tick
B21A2Chunk completions are recorded as inputs
B35nonePlayer persistence, chat fan-out and respawn search move out of Player into services keyed by player identity, while Player is still a legacy entity
B48noneMeasure the bytes in a save pass and confirm what a saved player record copies, so ADR 0018 can be accepted or changed
B55noneRe-key the seven maps keyed by the Player object by player identity or entity id

B1 and B2 both edit the ticker, so B2 follows B1. B3, B4 and B5 run beside them.

The lane declarations of A3 put 35 handlers in the connection lane, 9 in the read lane and 28 in the input lane, and left B1 these rules.

  • An input-lane packet is applied in the input step of the world its sender's entity is in. An effect on another world leaves as a message under ADR 0011 and is admitted at that world's next input step.
  • Server-wide state that is not a world's authoritative state, such as the operator list, the default game mode and chat fan-out, changes in memory when the handler runs and persists off the tick.
  • A player's removal is an input, whether a Disconnect packet or a closed transport caused it.
  • SubChunkRequest can load a chunk the world evicted. The handler may wait for it, and the loaded chunk is admitted through the completion queue like any other.
  • Login and ClientToServerHandshake stay in the connection lane although they read the default world's spawn and build the player. That work moves in front of the join in slice 6.

Slice 6 inherits a list of what stops an input-lane handler from being synchronous. Eight handlers load chunks or search for a position, among them PlayerAction, InventoryTransaction, ItemStackRequest, Respawn and RequestChunkRadius. Six reach the disk through savePlayerData, the operator file or the config. Text, CommandRequest and SettingsCommand call into commands and chat, which can do anything. Six read-lane handlers are write requests the server drops today, and each moves to the input lane when its write is implemented.

B2 left three things for later slices to remove.

  • world/TickContext.ts uses AsyncLocalStorage to tell code inside a tick from code between ticks, because the tick still awaits. Slice 9 makes the tick synchronous, and a plain flag set by the ticker replaces it then.
  • Eight code paths still load a chunk from inside a tick. Each is admitted inline, recorded as inline and listed in IN_TICK_CHUNK_LOADS, and a test fails when one is removed without updating the list. Slices 3, 5, 6 and 9 remove them.
  • World.disable waits for an unload parked on admission, so calling it from inside a tick would never finish. Nothing does today. Slice 9 has to keep it that way or drain admissions first.
  • A tick that awaits work started outside it, such as from an event or a command between ticks, cannot be told apart from one that waits for its own chunks, and a chunk that work loads waits for the next input step. The ticker therefore admits every held completion inline, recorded as inline, and logs a TickStalledError each TICK_STALL_MS a tick runs with completions held. Slice 9's synchronous tick removes the wait and the watch with it.

C1a and C1b left these for later packages.

  • C1a moved component state the entities package hid in WeakMaps onto EntityInstance.componentState, with one write path that reports each write's before and after values to EntityEnvironment.componentStateWritten. target_nearby_sensor keeps a runtime id there, and runtime ids are drawn again each session, so C1c marks that field as not saved until the store has ids that survive a reload.
  • C1b left one index per dimension (EntityIndex, World.getEntityIndex()), and chunk membership as a second structure written only by World.entityRelocated. Membership follows the client body and the index follows the runtime's position, so the two agree again once C3 deletes the mirror. Until then legacy explosions search four blocks beyond their reach.
  • Viewer tracking still passes every entity in the world to EntityTracker.plan. D4 replaces that with audiences computed from the index.
  • Inside the tick, World.getEntities() is still copied for spawner counts, the attribute flush and tracking, and EntityRuntime.all() is copied for the entity pass, the despawn cycle and the mirror's sync. Slice 5's target removes the copies.

C1c left these for later packages.

  • The store is EntityRuntime.store, which took over the runtime's entity map, and it holds the world's change log until block writes join it in D3. A log tick is numbered by the store, not by the level clock, which /time set moves backwards.
  • A player's inventory was not logged: a slot changed by mutating the Item in place and Inventory did not know its owner. C4c closed this, as the notes below say. Legacy position, health and game mode are logged through entity/legacyAdapter.ts.
  • A merchant's offers are saved and hashed but not logged, because a trade changes the Merchant in place.
  • An undo restores only what a save or the digest reads. Goal progress, pending deaths and other runtime bookkeeping stay as the throw left them. A review later found that a death or grace period left this way refuses every later blow, so a unit now also undoes the runtime state it registers through EntityStore.undoWith: the death in progress, the grace period, the damage record and the fuse's start. The same review found writes outside the log that survived an undo in the join, a player's death and a kick. A player's entry now undoes the player map and the store player's hold on the entity through undoWith. What cannot be put back waits for EntityStore.afterUnit, which runs once the outermost unit has ended and says whether its writes were kept: a player's death sends and its respawn search run only on a kept unit, the join's guard and play phase are set only when its input commits (whenInputCommits), and a leave called inside a unit, as a kick is, runs once the unit ends either way.
  • A chunk admitted inline inside a unit pins the unit, which then keeps its writes if it throws, until slices 3 to 9 remove the inline loads.
  • Writes between ticks, such as tick functions, which run after the worlds seal, and connection-lane handlers, are counted as unlogged and not logged.
  • A player whose update throws is not contained yet: legacy updates do not run as units. When players move into the store, the store's failure count disconnects the session instead of removing the entity.

B3b left these for C3 and C4.

  • Chat fan-out is ChatManager. A player joins it as a ChatRecipient, keyed by keyOfPlayer, with a receive callback, and the manager decides the audience and builds the line. Each recipient is still its own chat listener, because a listener that cancels the event stops only the players who joined after it. One listener with one audience pass is the shape D4 wants, and it changes which players such a listener stops, so it belongs with the guards of ADR 0013 or the event bus of slice 10.
  • Respawn search is RespawnSearch in world/RespawnSearch.ts, one for each level (Level.getRespawnSearch). find(request) takes the player's key, saved spawn point and body as plain data. It answers with spawn (own or world), the dimension, the three exchange positions and spawnPointLost, and writes nothing. Every world read is behind its RespawnWorlds port: building the bed's dimension, the bed and feet checks, and the world spawn, which can still run the search for dry land.
  • Player.getRespawnPoint is the adapter. It builds the request, applies spawnPointLost by clearing the point and sending tile.bed.notValid, and resolves the answer's world. onDeath, respawn, RespawnHandler and DimensionTravel.leaveEnd call it. For C3, onDeath sends the request instead of awaiting the adapter, and the input that admits the answer applies spawnPointLost and sends the SearchingForSpawn. The in-tick chunk load entry for the search names RespawnSearch.ts and slice 4.
  • The request does not carry the world spawn. Reading it before the search would run the search for dry land even when the player's own point holds, so the service reads it only when it falls back to it.
  • For C4, the request's spawn point comes from the store entity's component, and the chat recipient's receive finds the session through the session registry by identity.

C2 left these for later packages.

  • Dropped stacks reach clients through entity/data/itemBodies.ts, an ItemActorBody per stack that reads the store entity and goes on the wire as an AddItemActor. It sits apart from the mirror, so whatever replaces the mirror for the legacy tracker can take it over too. The eye of ender has no display code of its own and shows through the mirror like any store mob.
  • The item bodies still run Entity.update, which is how a stack reaches a portal. Portal travel adopts a stack that arrives in another world into that world's store from the body's saved form. Slice 9 turns travel into messages and removes this.
  • A stack's age is component state, so each stack appends one component entry every tick it lies in a ticking chunk, besides its position and speed while it moves. An idle world with stacks on the ground is not an idle log.
  • The eye's shatter roll draws on the dimension's random, where the legacy eye drew on the server's.
  • A drop now goes straight into the store, so it no longer waits on World.addEntity. World.ITEM_ENTITY_LIMIT counts the item bodies.

C4c left these for later packages.

  • Every write to a slot of a player's main inventory, armor, off hand, ender chest, cursor or crafting grid appends one inventory-slot entry. Its value is the stack's saved form (inventory/slotLog.ts), the same form the player record keeps, so an undo restores what a save and the digest read. The hand slot is the entity field inventory.handSlot. Inventory.observe, HumanInventory.observeCarried and CraftingInput.observe report the writes, and Human wires them to the adapter.
  • PlayerComponents (entity/playerComponents.ts) holds vitals (food, saturation, exhaustion, drowning ticks, fall distance), experience, flying, the movement flags (sneaking, sleeping, on the ground), the spawn point, the credits flag and the tick an item use began. Each field is logged as <component>.<field>. Food, saturation, exhaustion and experience stay in the attributes and the component reads them there. Player's private fields are accessors over it, so the death, respawn and damage paths were not edited and are logged too. C4e and C4f can move this object onto the store entity unchanged.
  • The replay digest hashes Player.componentSnapshot, so digests of a world with a player changed once, in a commit of their own. The replay suite still finds two replays identical.
  • Not logged yet, for C4d: air (metadata.air, whose owner C4d decides), sprinting, the block being mined, the meal in progress, which still holds a closure, and every attribute other than the ones above, such as movement speed and luck. Absorption is on the damage path, which C3 owns.
  • A stack changed in place is not a write. The inventory keeps each slot's last logged value, so the next write to that slot still carries the right before-value, but the change lands in the tick of that write. Two paths did this and were converted: wearing a tool, now Item.withDurability, and a partial /clear. ItemStackRequestHandler.itemOrNull still sets an item's count from its entry in place, which changes no saved form. A slot whose entry count and item count disagree logs the entry's count only.
  • A stack network id is session state and is not in a slot's value, so an undone write gives the stack a new id. Replication has to send the slot again (slice 7).
  • A workstation's input slots and a chest's slots are block entity state, which D3 logs.
  • Entity.restoreLoggedField and restoreLoggedSlot, which Human and Player override, are how the adapter's inverse writer reaches a legacy player. C4h deletes them with the adapter. Test doubles built from Player.prototype without the constructor get their components from withPlayerComponents in testing/playerDouble.test.helper.ts, which C4h deletes with them.
  • Player.getExperience replaced six hand-built experience holders, so every experience write goes through the component.
  • The saved player record is unchanged: PlayerPersistenceFormat.test.ts passes byte for byte.

C3 left these for later packages.

  • Every blow a store entity takes is a DamageIntent run by DamagePipeline (entity/data/damagePipeline.ts, damageStages.ts): a mob's bite, an area attack, a projectile, a blast, a player's swing, a status effect, a command, an item action, a component hazard and a dropped stack's fire, lava and cactus. The guards are present, invulnerable, damage_resistant (a dropped stack's item), hit_grace, cannot_be_attacked, dragon, damage_sensor, the fireDamage game rule and the before-hooks a host adds. The modifiers are the grace difference, armor, protection, resistance, absorption, the dragon's scaling and the sensor's multiplier, in that priority order, so the sensor still scales what armor left, as it did when the mirror handed the runtime the body's loss.
  • A blow from the legacy code keeps the rules the legacy pipeline gave a mob through DamageSourceFacts: the grace period, the mitigation and the shove. A mob's own attacks keep none. legacyDamage.ts turns a DamageSource into an intent and maps the legacy causes to Bedrock names, so a sensor sees a player's swing as entity_attack.
  • A veto appends one action-rejected occurrence and nothing else. The grace period is a runtime map, not logged and not saved, as the legacy body's was not. An undone unit puts it back through EntityStore.undoWith. The sensing is memoized for one run in DamageSensingSlot, which the sensor guard, the multiplier and the commit share.
  • A death runs inside the commit of the blow, queued minecraft:on_death events first, where it waited for the entity's next tick before.
  • The plugin entityDamage event is a before-hook that can cancel a blow or change its amount, and entityDeath and the advancement triggers are raised from EntityHostFacts.damageCommitted, inside the blow, before a death removes the victim. The gameplay.combat-advancements parity family checks that timing. A player's PlayerSession.die waits for the blow's unit to keep its writes (EntityStore.afterUnit), so an undone death sends nothing. Slice 7 has to keep it when observers read the log.
  • Health is the store's alone. The comparison that handed a body's loss back to the runtime is gone, and a body reads its health from the store and heals through it.
  • Clients are shown store entities through StoreActors (entity/data/storeActors.ts), one per store entity, which answer the network layer from store state through the structural ShownActor (world/ShownActor.ts). StoreActors.sync sends what changed since it last sent each entity, which is replication's work until slice 7 reads the log instead. Nothing in it is mob-specific; the eye of ender shows through it. Dropped stacks still show through C2's ItemActorBody, which the actors can take over.
  • The legacy bodies (LiveBodies) stayed in each world's entity map, written silently and never announced, because the legacy code still reached a mob through them: commands and selectors, legacy blasts and swings (damageEntity), interactions and trades, chunk saves and the replay digest (body NBT and attributes), portal travel, spawner counts, the dragon fight and riding. C3b moved those to the store actors and deleted the bodies.
  • damage is synchronous for store entities. A legacy player's Entity.damage is still async, because its sends are awaited, and live.ts still starts it without awaiting for a mob's blow on a player. That goes with C4b and slice 7.
  • A player's death no longer awaits the respawn search. onDeath files it with RespawnRequests (world/RespawnRequests.ts), and the next input step awaits every search filed before it, in order, then applies spawnPointLost and sends Respawn and DeathInfo. Awaiting in the input step keeps the applying tick independent of host speed, but the search still loads chunks inside the tick, so its IN_TICK_CHUNK_LOADS entry moved to slice 9, where such loads become recorded inputs. RespawnHandler and respawn still search as they run.

C3b, which deleted the legacy mob bodies, left these for later packages.

  • Every store entity but a dropped stack reaches the legacy world code through its StoreActor (entity/data/storeActors.ts), the same object that shows it to clients. World.getActors and World.getActor list and find a world's entities, a store entity as its actor; World.getEntities and World.getEntity hold only legacy entities, which are players and the bodies of dropped stacks. WorldActor (world/WorldActor.ts) is the union of the two, and it is what DamageSource's attacker and projectile, the entityDamage, entityDeath and entityPortalEnter plugin events, the advancement triggers, the death messages, the trade window, the save context and the providers take. Slice 5 narrows it to the actor.
  • The actor answers from the store and keeps no copy of it. A blow on it runs DamagePipeline, a shove and a teleport write the body, and a tag or a status effect goes into the entity's state, filtered by its minecraft:mob_effect_immunity. It keeps three things the store does not model yet: the actor record tags a save carries forward (persistentNbt, which a save writes the record into), the portal cooldown and contact, and the clock of the status effects. Slice 9's travel as messages takes the portal state; the other two belong with replication's replica (slice 7) or with a status effect component.
  • The world updates each actor in its tick, after the legacy entities, which runs the portal clock, a trip once its contact is ready, and the status effects, whose harm and healing go through the store. The bodies did this in Entity.update, interleaved with the legacy entities in the order the entity map held them. DimensionTravel moves a store entity by restoring its record in the destination store, with its actor record tags, tags and status effects.
  • Chunk membership of a store entity is the index. Its cells are sixteen blocks wide and aligned to chunks, so EntityIndex.chunkChanged reports the chunk each move is in and the chunk a crossing left, and the world marks them for the next save, as the body's position writes did. A chunk that leaves the world takes the store entities EntityIndex.storeInChunk files in it. C1b's two structures no longer disagree, and a legacy blast no longer searches four blocks past its reach: it asks for the legacy entities (legacyWithin) and the store's (storeWithin) apart.
  • The replay digests did not change: an actor carries the tags, attributes and metadata the body carried, and the digest hashes the actors in the order it hashed the bodies.
  • The in-tick copy of EntityRuntime.all() for the mirror's sync is gone, and spawner caps walk the entity map and the actors in place. World.getActors copies and sorts for selectors, saves, the digest and the content adapter's at, near and countOfType, which slice 5's target removes. No IN_TICK_CHUNK_LOADS entry went through a body, so the list is unchanged.
  • The proxy target "no mirror class and no health comparison" gates now. entity/data/noMirror.test.ts fails if liveBodies.ts returns or another legacy class wraps a store entity, and live.test.ts checks a running world: a store entity shows through its actor with nothing in the entity map, and a health written onto an actor is not handed back to the store.
  • C2's ItemActorBody stays. A dropped stack still reaches a portal through Entity.update, goes on the wire as an AddItemActor, and merges, is picked up and is unloaded as a legacy body, so folding it into the actor means moving all four paths, which is not small. Slice 9's travel as messages removes the first and makes the rest a display question.
  • /summon has no command form that passes a name: only the command host's entities.summon does, and it now names the entity in the store, where the body's name had reached no client since C3.
  • In-game checks for the orchestrator's combined pass: name a mob with a name tag, lead it, ride a saddled pig or a horse, trade with a villager, and walk a mob through a nether portal, then save, restart and find each where it was.

C4b left these for later packages.

  • The player overlay is code, not a pack file: entity/playerOverlay.ts, applied in buildServerContent to whichever pack's minecraft:player file won the stack. A file under vanilla_overlay/entities/ would replace the shipped player file whole, where the overlay adds a component only when that file declares none. It adds minecraft:health 20 of 20 and nothing else. The raw definitions in ServerContent.definitions still hold the file as it shipped; the resolved type carries the overlay. The entities.behavior parity family checks it as player-health.
  • The store's three player rules key on clientOwnsMovement(identifier), which is true of minecraft:player alone. A player's update in the entity pass stops after its timers, its components and the void check, so it gets no brain, navigation, coast, step or pushes, and isPushable is false for it while it still pushes mobs. MobBody.onImpulse turns every push on a player's body, a blow's shove included, into an impulse occurrence and leaves its velocity alone. EntityRuntime.applyClientMovement writes what the client reported, and MobBody.teleportTo stays the server's move. kill keeps a player: it stays in the store and in dying, so the present guard vetoes further blows, until EntityRuntime.revive brings it back at full health with one health entry and no membership entry. No live player is a store entity yet, so these rules run only in entity/playerOverlay.test.ts. The package that spawns one (C4e, C4g or C4h) also has to keep it out of chunk saves, which skip only the legacy class today (ChunkSaveContext.ts), and out of the despawn cycle.
  • For the live player, every server move is acknowledged. Player.setPosition always sends a teleport, where it sent a Normal move that registered nothing, which chorus fruit used. A move event's reset (PositionMode.Respawn) registers the same acknowledgment a teleport does. A join, a respawn and a world change move the player without a MovePlayer because their own exchanges place the client. Every handler whose source moves a player is in the input lane, and Lane.test.ts checks that.
  • A shove on a live player is logged as an impulse occurrence beside the motion PlayerSession.sendMotion still sends, until slice 7 sends it from the log.
  • A live player's death keeps its runtime id, as the legacy class always did, and appends no membership entry. respawn()'s attribute reset is now logged: health, and the component fields the attributes hold, through PlayerComponents.reportingChanges. A respawn into another dimension moves the player between worlds, which is a transfer under ADR 0011 and keeps the id; once players are store entities that is a leave from one store and an enter into another.
  • A blow on a live player commits before it waits. Entity.damage starts its packets instead of awaiting each, and Player.onDeath posts entityDeath, raises its advancement triggers and files the respawn search before its first wait, so the health, the grace period, the death and the request are written when the call returns. What remains: the blow still runs the legacy rules in Entity.damage, not DamagePipeline, so a plugin's entityDamage handler and the grace period differ by path; the promise still covers the packets, which live.ts starts without awaiting; and applySonicBoom waits for that promise to learn whether the blow landed before it shoves. Running a player's blows through DamagePipeline needs the player to be a store entity, which is C4h's.
  • No client e2e scenario covers riding, because /ride is unsupported (command/host/entities.ts); a player riding a mob is checked in game. The new player-knockback scenario covers a mob's shove on a player.
  • Found on the way: chorus fruit puts a player's eyes where their feet belong (#2961). Run alone after fall-damage and damage, the dimensions scenario fails on the health those left, because nothing heals between scenarios. It passes alone, and CI's full suite has passed with it.

C4e left these for later packages.

  • The record is a projection with one home, world/playerRecord.ts. playerRecordFrom(player, services, dimension) builds it and applyPlayerRecord(player, data, defaultGamemode, content) puts one back, through two structural ports, PlayerRecordSource and PlayerRecordTarget. recordedPosition reads where a record puts a returning player. PlayerPersistence still owns where a record is read and written and the key it is filed under (keyOfPlayer).
  • A live Player satisfies both ports. World.savePlayerData calls playerRecordOf, which reads the advancements and the enchantment seed from their keyed services and calls playerRecordFrom, and Player.enable calls applyPlayerRecord and recordedPosition. Player.restoreHealth and Player.restoreFlying are the two silent writes the load needs. Live behavior did not change, and PlayerPersistenceFormat.test.ts still passes byte for byte.
  • A store player is StorePlayer (entity/storePlayer.ts): the minecraft:player store entity, which holds the position, the look and the health, and beside it the parts the store does not model yet, in the objects a live player holds them in: the client's attribute table, PlayerComponents, HumanInventory, the game mode, the portal cooldown, the head yaw and the identity. The record holds the eye, so StorePlayer adds EYE_HEIGHT to the entity's feet and placeAt takes it off. A y that is not exact in binary can move by its last bit across a save and a load. The record's attribute table takes its health entry from the entity's health and maximum, so a store player saves the table a live player saves. The format test builds a store player the way C4b's tests do, writes both fixtures byte for byte from it, loads each fixture into a fresh one and writes the same bytes again.
  • What C4h has to switch on:
    1. Where store players live. Nothing builds a StorePlayer outside tests. C4h keeps one per player entity, keyed by entity id, builds it at login from the spawned minecraft:player, and applies the record before the entity enters the store, so the load appends no entries, as the live load runs before the world holds the player.
    2. Logging. A StorePlayer's components and inventory log nothing, because EntityRuntime.writeField throws LoggedFieldError on a field it does not know, and an undo would then stop the world. C4h wires them to EntityStore.written and writtenSlot and adds an inverse writer for the player fields, which replaces the adapter's restoreLoggedField and restoreLoggedSlot.
    3. The keyed services. storedAdvancements, loadAdvancements, forgetAdvancements, enchantmentSeedOf and forgetEnchantmentSeed take a Player and use only its key and the server. They need a form that takes the key, and then World.savePlayerData, World.getPlayerData and lastPlayerRecord, which take a Player today, take the key and the store player.
    4. Copies to delete with Player. StorePlayer.setSaturation and setPortalCooldown repeat the clamps of Player.setSaturation and Entity.setPortalCooldown, and EYE_HEIGHT lives in entity/Human.ts, which C4h deletes.
    5. The maximum health in a record's table is not applied to a store entity, whose type decides it. Both are 20 today.
    6. The head yaw is kept on the StorePlayer, because MobBody has none and EntityRuntime.applyClientMovement takes only the yaw and the pitch.
    7. C4b's note still applies: a store player has to stay out of chunk saves (ChunkSaveContext.ts) and out of the despawn cycle.
  • A saved player record is a deep copy, which closes the question under "Checks to run". Checked again at this package: storedInventory builds new arrays and builds each stack from primitives, the attribute table, the experience and the position are numbers, and the advancements' toJSON builds new objects. The one shared object was the spawn point, which was the component's own value. A write replaces it and never changes it in place, but playerRecordFrom now copies it, so a record held until a later commit under ADR 0018 shares nothing with the player. inventory/persistence.test.ts asserts it for the inventory, and the format test for a whole store player record.
  • The client e2e rejoin scenario is new. It leaves the Nether hurt and logs back in, and checks the dimension, the position and the health. No scenario left and rejoined before. dimensions, spawnpoint-respawn and death-respawn pass with it.
  • In-game checks for the orchestrator's combined pass: log out with a damaged, enchanted and renamed tool in the hand, armor on, a shield in the off hand and items in the ender chest, on part health and food, in the Nether, with a bed set in the Overworld, and log back in. Everything comes back, and dying sends the player to the bed.

C4g, which shows store players to clients, left these for C4h.

  • A store player is an entity of type minecraft:player with a StorePlayerActor (entity/data/storePlayers.ts), a StoreActor that other clients are sent through PlayerSession.sendAddPlayer: the AddPlayer, then the abilities, the armor and the status effects. A legacy player goes through the same method, and through the same sendAbilities, sendArmorFor, sendGameTypeFor and playerListEntryOf (network/playerListEntry.ts), which read the structural ShownPlayer (world/ShownPlayer.ts). PlayerSession.sendSpawn and sendDespawn are gone. The moves, metadata and equipment changes go out through StoreActors.sync, one move a tick at most, and removal from the store sends a RemoveActor to each client that tracked it, which covers a leave and a dimension change. A death sends nothing, because the player stays in the store. entity/data/storePlayers.test.ts counts these packets with real sessions and the real replicator.
  • What the store does not hold yet the actor reads from C4e's StorePlayer: the identity, the head yaw, the game mode, flight, and the held item and armor from its HumanInventory (HumanInventory.getEquipment, which Human.getEquipment now calls too). StorePlayer gained the skin, the device and the platform chat id. StorePlayers, one for the server in EntityRuntimes.players, files each store player by entity id, which is the registry C4e's first switch item asks for, so a player that changes dimension is still found if it keeps its id. A minecraft:player entity with no filed store player is shown as a plain actor.
  • To switch, C4h spawns each live player with spawnStorePlayer(runtime, entityRuntimes.players, feet, identity, fill). It builds the StorePlayer and files it before the entity is announced, because the first AddPlayer is built in the same call, and fill is where the saved record (applyPlayerRecord) and the login's skin and device go. It writes the name as the entity's name tag, as a legacy player's name is its metadata's. The position is the feet; StorePlayer.getPosition gives the eyes the wire carries. A dimension change restores the player into the destination store with the same id (EntityRuntime.restore(record, at, id)), and players.delete(id) runs when the player leaves the server. A skin change writes StorePlayer.skin.
  • The viewers are still World.getPlayers(), legacy players with sessions, in flushMovement, the observer path, getViewers and entityAdded. Six methods key on entity instanceof Player and have to find a store player's own session by entity id instead: entityAdded, which shows an arriving player the world, and the own-client copies in actorEffect, entityAttributesChanged, statusEffectChanged, entityMetadataChanged and entityEquipmentChanged. The observer path now skips the client whose entity changed by runtime id, as the movement flush did, so the own client gets no second copy.
  • The head yaw on the wire is StorePlayer.headYaw, which C4h's input step writes beside EntityRuntime.applyClientMovement, since that takes only the yaw and the pitch (C4e's sixth item). Observers are sent equipment changes from the inventory by comparing it each tick, which builds one equipment set for each store player a tick until slice 7 reads the slot entries instead.
  • Metadata is the components' projection plus the name tag. The legacy player writes can-fly from the game mode, sprinting, breath and air into its metadata, and each needs a logged home that the projection reads; air and sprinting are C4d's. No server code writes the sneaking flag for either kind of player, so other clients may not see a player crouch; the combined pass checks it. Observers are sent no attributes for a store player, as for a legacy one, and the player's own client gets its attributes from its session.
  • The held item reaches viewers twice once MobEquipmentHandler addresses a store player: its relay, with the hotbar slot, and StoreActors.sync's equipment change, with the slots at zero and the armor beside it. C4h deletes the relay. Armor and off-hand changes then reach viewers at once, which they do not for a legacy player (#2965).
  • Game mode, abilities, skin and the player list are still triggered by legacy code: Player.setGamemode, Player.enable, PlayerSkinHandler and ResourcePackClientResponseHandler. Each calls a method that takes a ShownPlayer now, so C4h passes the actor. StoreActors.sync does not notice a game mode change, so the code that changes it sends sendGameTypeFor and the abilities, as setGamemode does.
  • Animations, sounds and actor events relay by runtime id through PlayerSession.tracks, so they reach a store player's viewers once their handlers address it.
  • The store player is not kept out of chunk saves or the despawn cycle yet, which C4b's note gives to the package that spawns live players.
  • In-game checks for the orchestrator's combined pass, with two vanilla clients once C4h lands: the second player sees the first appear with its skin and name, walk, look around, swing, change its held item and armor, sneak, change game mode, die and respawn, change dimension and leave, and the player list shows both throughout. Until C4h, the same checks on a live server confirm that nothing changed for legacy players.

C4d, the player systems, left these for C4h.

  • Each survival rule has one home, over a SurvivalPlayer (entity/survivalPlayer.ts) that hands it a player's PlayerComponents: hunger, exhaustion, saturation, natural regeneration and starvation in entity/playerHunger.ts, breath and drowning in playerBreath.ts, fall distance and landing damage in playerFall.ts, the meal in progress in playerMeal.ts, crack animation in playerMining.ts, and what a death and a respawn reset in playerRespawn.ts. A rule writes through the components and returns the damage or the heal for its caller to deal, because a legacy blow still waits on its packets. The legacy Player calls the rules for its own components, so live behavior is unchanged but for the two fixes below.
  • PlayerSystems (entity/data/playerSystems.ts, EntityRuntime.playerSystems) runs them over every store entity the client moves. The entity pass ticks breath and hunger for a player they reach, then the meal and the mining; applyClientMovement adds the move to the fall, charges the walk and pays for a landing; the blow that kills a player ends its drowning and its mining, and revive resets its attributes, lungs and movement flags. Blows go through DamagePipeline with the facts the legacy path gives the cause (route: 'component', graced and mitigated). Each write is logged through EntityStore.written and undone through EntityRuntime.writeField, which hands a player component field to PlayerSystems.restore. entity/data/playerSystems.test.ts tests each system against a store player.
  • One store player has one set of components: new StorePlayer(entity, identity, runtime.playerSystems) builds them through PlayerSystems.attach over its own attribute table, which closes C4e's second switch item for the components (the inventory still logs nothing) and its fourth for the saturation clamp. Built without the systems, as nothing outside tests does, a StorePlayer keeps unlogged components.
  • Everything C4c left unlogged is a component field now: vitals.air, vitals.breathing and movement.sprinting, kept in the actor metadata as food is kept in the attributes, mining.block and consumption.meal as plain data, and attributes.<name> for absorption, knockback resistance, the three movement speeds, follow range, attack damage and luck. A legacy write to one of those attributes goes through Entity.setAttributeValue. The meal is data: what a finished meal does is the held item's onConsume, resolved when it finishes, and a store player reaches its hand and the finish through the host facts heldItemOf and finishMeal, which no live host supplies yet.
  • Breath has one owner, the breath rule. A store player's shipped minecraft:breathable keeps no air: PlayerSystems.beforeComponents clears its state before every component tick, so its own tick does nothing. That appends one component entry on a player's first tick. The rule keeps today's numbers, which differ from the shipped component's (five ticks of air back a tick, not four; two damage every twenty ticks, not every ten).
  • Every system reads loaded chunks only, and IN_TICK_CHUNK_LOADS lost the breath and landing checks. A head in an unloaded chunk waits: no air is spent or regained. A crack tick on an unloaded block sends nothing and the mining goes on, where it stopped the cracks. A landing on a block nobody can read costs nothing, as before.
  • Fixed on the way: the landing check read the blocks under a legacy player's eyes, which the client reports, so hay, slime or a bed under the feet never broke a fall. The fall-damage client scenario lands on hay now.
  • The replay digest hashes the new component fields, so absolute digests of a world with a player changed. Two replays still match, and SessionReplay.test.ts passes unchanged.
  • player-survival in the entities.behavior parity family compares the rules with Java and the shipped player file: a lungful of 300 ticks, two damage twenty ticks after the lungs empty, the fall damage formula, and the exhaustion costs in minecraft:exhaustion_values. Bedrock ships a walk cost of 0 and the server charges 0.01 a meter, which the scenario leaves out.
  • What C4h has to switch on:
    1. Inputs. The input step calls PlayerSystems.beginMeal and cancelMeal where InventoryTransactionHandler calls beginConsuming and cancelConsuming, setMining where PlayerActionHandler sets the mining (after World.startBlockCracking, which sends the start), and the shared addJumpExhaustion rule and the movement.sprinting field where PlayerActionHandler handles a jump and a sprint. A server move of a store player (MobBody.teleportTo) has to zero vitals.fallDistance, as a legacy teleport does; nothing does that yet.
    2. Game mode. Every store player counts as a survival player (SurvivalPlayer.vulnerable is always true) until its game mode is a component the systems read. A creative store player would starve, drown and take falls.
    3. Ground state. The fall rule reads a store player's ground from the body's on_ground condition, which applyClientMovement writes, and the movement.onGround component field stays stale. One of the two has to go.
    4. The meal clock. A store meal counts out on the level clock, a legacy one on the server tick. A live host has to supply heldItemOf and finishMeal, or no store meal finishes.
    5. Metadata. C4g's note asks for logged homes for air, breathing and sprinting: they are vitals.air, vitals.breathing and movement.sprinting of the store player's components, held in the components' own Metadata. StorePlayerActor's projection does not read them yet, and the breath packet throttle (Player.syncBreath) has no store counterpart; replication sends them from the log in slice 7.
    6. The mining runtime id. A store player's crack tick reads ContentBlockAccess.runtimeIdAt, which the live adapter supplies. A host without it never stops a store player's cracks.
  • Found on the way: starvation ignores the difficulty (#2968), and a player inside a solid block takes no suffocation damage (#2969). Hay, honey and beds cancel a fall outright where Java and Bedrock take most of it, which entity/Environment.ts records as a known simplification.
  • In-game checks for the orchestrator's combined pass: sprint until the food bar drops, starve on Hard and heal on a full bar, swim under until the bubbles run out and drown, surface and watch the bar refill, fall eleven blocks onto stone and onto a hay bale, eat a steak and switch items halfway through a second one, mine a block slowly and have another player break it, and die while mining underwater, then respawn with full food, air and a standing body.

C4f, the session's entity id and the split of the conversions, as of its first run.

  • A PlayerSession holds its player's runtime id (runtimeId) and login identity (identity, a PlayerIdentity), and no Player. getPlayer() is the one accessor from a session to its player's state: it finds the player by id in a SessionPlayers directory (network/sessionPlayers.ts). Live players are filed in LegacyPlayers, one for the server, from SessionManager.getLegacyPlayers(). createConnectedPlayer files the player, and PlayerSession.disable forgets it, after which getPlayer() throws and isConnected() answers false. SessionManager.claimIdentity reads the held identity.
  • The world's entity map cannot be the lookup: a player is not in it from the login to SetLocalPlayerAsInitialized, nor between the two worlds of a dimension change, and the session speaks for it the whole time. The directory has the of and delete of StorePlayers, so C4h's switch is the directory createConnectedPlayer hands the session, EntityRuntimes.players in place of LegacyPlayers, and the accessor's return type. C4h then deletes LegacyPlayers and sessionPlayerRefOf.
  • Session methods that await read the player once before their first await, so a player that leaves during a send behaves as before. Tests build sessions with sessionFor and sessionPlayerFields, and test servers get a real directory from sessionsWith (testing/playerSession.test.helper.ts).

Converting a file means replacing each Player it names with the narrowest of these: session.runtimeId or session.identity (and playerKeyOf(identity) for a keyed service), a ShownPlayer, a structural port the file declares for what it reads (as SurvivalPlayer and PlayerRecordSource do), or, for state the store does not hold yet, session.getPlayer() narrowed to such a port. git grep -lP "\bPlayer\b" -- 'packages/**/*.ts' finds 86 Prismarine source files at this run, and 60 more reach the player through getPlayer() or getNetworkSession() without naming it, 146 in all. The other packages' 49 hits are Bedrock's own word (MovePlayerPacket, the Player command origin), not the class. The 127 test files and test helpers that name it (the spike counted 117) are C4h's, except the tests beside a converted file, which its group updates.

GroupFilesWhat the files need
F1, handlers that only name the player42The id and the identity: a log line's name, a comparison with the sender's runtime id. Six pass the player to blockTargetRejection, and BlockTarget.ts narrows that parameter to the position and world it reads
F2, handlers that change the player28The identity for logs, and the player's state through session.getPlayer() narrowed to a port per use: inventory and crafting input (ItemStackRequest, ContainerClose, BookEdit, MobEquipment), position and world (Interact, LevelSoundEvent, ActorEvent), game mode, flight and skin (SetPlayerGameType, SetDefaultGameType, RequestAbility, PlayerSkin, a ShownPlayer for the skin), the join (Login, ResourcePackClientResponse, SetLocalPlayerAsInitialized), movement and survival (MovePlayer, PlayerAction, InventoryTransaction, Respawn), and a kick or a disconnect as a session operation (Disconnect, ResourcePackChunkRequest, PlayerAuthInput). CommandRequest, SettingsCommand, Text, RequestPermissions and BossEvent hand the player to F3's or F4's code and keep passing session.getPlayer() until those take something narrower
F3, sessions, replication and worlds16PlayerSession's remaining 40 accessor reads (view distance becomes session state; position and world for chunk streaming, inventory and attributes for its own sends), NetworkWorldReplicator finding a viewer's session by entity id (C4g's six instanceof Player methods), BossBar keyed by runtime id, SessionManager's lookups by id and identity, World's player map, viewers and savePlayerData keyed by keyOfPlayer (C4e's third item), playerRecord, PlayerRecords, Level, WorldChangeSink, DragonFight, DimensionTravel, spawnPosition, startGameData, NetworkDispatcher, and entity/data/live.ts and Entities.ts, whose host facts already answer by id
F4, services, commands, items and events30Keyed services: advancement progress, raising and triggers, and the enchantment seed take the key and the server, the ender chest store takes the key, bans and permissions read the identity. A port for state: anvil, item/actorPort, item/interaction and advancement/subject. CommandManager, command/host/* (7) and ParseTargetSelector take a sender port and reach a target's session by id. The ten events/player classes carry the id and the identity, plus what each reports
Nothing until C4h30Player.ts, Human.ts, Entity.ts, sessionPlayers.ts, createConnectedPlayer.ts, ClientConnection.ts (its teardown calls Player.disable) and index.ts are Player itself or its wiring. 23 files name it only in prose or a {@link}, among them storePlayer.ts, storePlayers.ts, playerSystems.ts, ShownPlayer.ts, RespawnSearch.ts and jsprismarineCommands.ts; C4h rewords them

F1 is the files ActorPickRequestHandler, AnimateHandler, AnvilDamageHandler, BlockPickRequestHandler, BlockTarget, ClientCacheBlobStatusHandler, ClientCacheStatusHandler, ClientMovementPredictionSyncHandler, CommandBlockUpdateHandler, EmoteHandler, EmoteListHandler, LabTableHandler, LecternUpdateHandler, MapCreateLockedCopyHandler, MapInfoRequestHandler, ModalFormResponseHandler, MultiplayerSettingsHandler, NetworkStackLatencyHandler, NpcRequestHandler, PacketViolationWarningHandler, PartyChangedHandler, PhotoTransferHandler, PlayerLocationHandler, PlayerToggleCrafterSlotRequestHandler, PositionTrackingDBClientRequestHandler, PurchaseReceiptHandler, RequestChunkRadiusHandler, ResourcePacksReadyForValidationHandler, ServerSettingsRequestHandler, ServerboundDiagnosticsHandler, ServerboundLoadingScreenHandler, ServerboundPackSettingChangeHandler, SetActorLinkHandler, SetDifficultyHandler, SetPlayerFurnaceOptionsHandler, ShowCreditsHandler, SimpleEventHandler, StructureBlockUpdateHandler, StructureTemplateDataRequestHandler, SubChunkRequestHandler, SubClientLoginHandler, UpdateClientOptionsHandler.

All four groups run side by side, each in its own worktree, because no file is in two groups. They keep to these rules so that none breaks another.

  • Narrow a parameter freely: a Player still satisfies any port drawn from it, so a caller in another group keeps compiling. Narrow a return type or an exported field only when every reader in the repository is in the group, and leave it for C4h otherwise.
  • A caller whose callee is in another group keeps passing session.getPlayer().
  • Only F3 edits PlayerSession.ts and SessionManager.ts. A group that needs a new session member asks F3 for it or waits for C4h.
  • No group edits Player.ts, Human.ts, Entity.ts or an existing file under testing/. A group that needs a test helper adds a file of its own beside the others.
  • Each group adds its own Changeset. F4's events and command host change plugin-facing types, which the versioning policy makes minor.

F2, the handlers that change the player, left these for C4h.

  • Every F2 handler logs session.identity.name and compares the runtime id a packet names with session.runtimeId. The rest of what it reads or changes of the player goes through session.getPlayer() typed as a port for that use. The ports two handlers share are in network/handler/playerPorts.ts: KickedPlayer, PlacedPlayer (the position and the world), InventoryHolder, CraftingHolder and FlyingPlayer. The others live in their handler: GamemodePlayer in SetPlayerGameTypeHandler, ReskinnedPlayer in PlayerSkinHandler, JoiningPlayer in ResourcePackClientResponseHandler, SpawningPlayer in SetLocalPlayerAsInitializedHandler, MovedPlayer in MovePlayerHandler, RespawningPlayer in RespawnHandler, ActingPlayer in PlayerActionHandler and Trader in tradeScreen.ts. The rest are picks of ShownPlayer (isOp, gamemode) and ShownActor (getType, and the whole actor for ActorEventHandler's relay).
  • Session operations: DisconnectHandler reads the address from the connection, which is the address the player was built with, and PlayerAuthInputHandler and InventoryTransactionHandler ask session.isConnected() in place of Player.isOnline(). After the player has left the server, a Disconnect still closes the connection and an input or a transaction is dropped, where getPlayer() threw before. PlayerSkinHandler matches the packet's UUID with session.identity.uuid and leaves the sender out of the relay by runtime id, as MobEquipmentHandler does.
  • What still takes session.getPlayer(), because the callee is another group's or C4h's: PlayerMoveEvent, PlayerSpawnEvent, PlayerSession.sendMove, createStartGameData, playerSpawnPositionPacket, World.addEntity, hitBlock, breakBlock, dropFromPlayer and an audience's exclude, the content interactions, meleeAttack, loose, consumeTicksOf, the enchanting and anvil code workstation.ts calls, advancementItemCrafted, containerAnchorOf and openContainerSlots, the command manager, the chat, the permission check and BossBar.answerQuery. workstation.ts does nothing with its player but pass it to the enchanting code, so it is unchanged. LoginHandler still builds the Player, which is C4h's.
  • What a StorePlayer lacks, by port. Each is a method a legacy Player has; the store half named after it is where C4h can find the state.
    • PlacedPlayer.getWorld, which twelve of the handlers read. A store player's world is the runtime it is in, which it does not hold.
    • KickedPlayer.kick. The session can own it, see below.
    • CraftingHolder.getCraftingInput: no crafting grid.
    • FlyingPlayer.setFlying: only restoreFlying, with no event and no abilities sent.
    • GamemodePlayer: isOp, getPermissions and setGamemode; only the gamemode field. StorePlayerActor.isOp answers the ShownPlayer picks.
    • JoiningPlayer: getJoinSpawn and sendSettings. SpawningPlayer: completeSpawn.
    • MovedPlayer: isOnGround, setPlayerPosition and setOnGround, which EntityRuntime.applyClientMovement and the on_ground condition cover.
    • RespawningPlayer: isAlive and getRespawnPoint. EntityRuntime.revive and the level's RespawnSearch cover them.
    • ActingPlayer: getMining and setMining (PlayerSystems.setMining), respawn (EntityRuntime.revive), addJumpExhaustion (the shared rule over the components), setSprinting (movement.sprinting), setSneaking, setSleeping and setFlying.
    • Trader.getExperience: private, though its PlayerComponents.experience() has it.
    • Pick<ShownActor, 'getType'>: none on StorePlayer; the entity's identifier has it. ActorEventHandler relays a ShownActor, which for a store player is its StorePlayerActor, not the StorePlayer.
  • Session members F2 needs from F3 or C4h, used through getPlayer() until then:
    1. PlayerSession.kick(reason, code). Player.kick is the connection's disconnect(reason, false, code) and a verbose log line, so the session can own it and Player.kick can call it. KickedPlayer then goes.
    2. A way from the session to the player's world, which F3's chunk streaming needs too. PlacedPlayer.getWorld then reads it.
  • Tests: the handler tests give their session doubles runtimeId and identity, the latter from identityNamed in testing/sessionIdentity.test.helper.ts. sessionNearBlocks in testing/blockTarget.test.helper.ts builds a session without either, so BookEditHandler.test.ts adds the identity to the session it returns, and F1's handlers that use the helper may need the same.
  • In-game checks for the orchestrator's combined pass: none beyond C4f's, since no handler's behavior changed.

C4h: live players in the store, and the legacy classes deleted ​

Planned on 2026-11-11 at program head 7265972882, after F1 to F4 landed. Its steps carry an H- prefix, because phases A to D already use the names A1, B1, C1 and D1. Counts are from that head. F3 and F4 wrote no notes on this page; their commit messages are the record, and what they left is folded in below.

1. The switch list. Every item the C4b to C4g and F1 to F4 notes left for C4h, deduplicated. "Done" means a later package closed it, and the step that spawns live players only adds a regression test. "Found" means no note listed it. The last column names the step in part 2.

#ItemFilesAsked byStep
1Spawn each live player as a minecraft:player store entity, its StorePlayer filed in EntityRuntimes.players. Apply the saved record before the entity enters the store, so the load appends no entriesLoginHandler.ts, createConnectedPlayer.ts, Player.enable, storePlayers.tsC4e 1, C4gH-B2
2A store player with no entity, from the login to SetLocalPlayerAsInitialized and between the two stores of a dimension change. StorePlayer's constructor takes a DataEntitystorePlayer.ts, storePlayers.ts, PlayerSystems.attachC4f states the gap; found, since no note designs itH-A1
3One id allocator outside Entity. Entity.runtimeIdCount is the counter both the legacy constructor and LiveEntityRuntime's nextId draw fromEntity.ts:103, live.ts:571, new entity/entityIds.tsadapter item 1; foundH-S0
4The session's directory becomes EntityRuntimes.players, and getPlayer() returns the store player. Delete LegacyPlayers, sessionPlayerRefOf and SessionManager.getLegacyPlayerssessionPlayers.ts, createConnectedPlayer.ts, PlayerSession.ts, SessionManager.ts, testing/playerSession.test.helper.tsC4fH-D
5Leaving removes the entity from its store and runs players.delete(id). ClientConnection's teardown calls session.getPlayer().disable()ClientConnection.ts:90, PlayerSession.disable, Player.disableC4f, C4gH-B2, H-C2
6A dimension change, and a respawn into another dimension, leave one store and enter the other through EntityRuntime.restore(record, at, id) with the same idPlayerSession.changeWorld, the addEntity and removeEntity calls for playersC4b, C4gH-S0 (entry points), H-B2
7The five instanceof Player in DimensionTravel: isMidDimensionChange, transfer, nether portal creation, the End arrival height and leaveEndworld/portal/DimensionTravel.ts:101,151,295,333,349F3H-S0
8Inventory logging: a store player's Inventory.observe, HumanInventory.observeCarried and CraftingInput.observe report to EntityStore.writtenSlot, and the runtime undoes its inventory-slot entries, which replaces restoreLoggedSlotstorePlayer.ts, playerSystems.ts, EntityRuntime.writeFieldC4c, C4e 2H-A1
9The inverse writer for player fields. Partly done: C4d's PlayerSystems.restore undoes component fields. gamemode and inventory.handSlot still go through the adapterplayerSystems.ts, legacyAdapter.tsC4e 2H-A1
10Game mode as a logged component field that SurvivalPlayer.vulnerable reads. Without it a creative store player starves, drowns and takes fallsplayerComponents.ts, survivalPlayer.ts, playerSystems.ts:311, storePlayer.tsC4d 2H-A1
11One ground state: the body's on_ground condition or movement.onGroundplayerFall.ts, playerComponents.tsC4d 3H-A1
12The meal: one clock, and a live host that supplies heldItemOf and finishMeal, which no host doesplayerMeal.ts, host.ts:130, new entity/data/playerHostFacts.ts, wired into live.tsC4d 4H-A1, H-B2
13The mining runtime id on a live host. Done: world/contentAdapter.ts:250 supplies runtimeIdAtnoneC4d 6done
14The input step drives the systems: beginMeal and cancelMeal where InventoryTransactionHandler calls beginConsuming and cancelConsuming, setMining after World.startBlockCracking, addJumpExhaustion and movement.sprinting on a jump and a sprint, the head yaw beside applyClientMovement, and a server move (MobBody.teleportTo) that zeroes vitals.fallDistanceplayerSystems.ts (H-A1), the Player methods the handlers call (H-B2)C4d 1, C4e 6, C4gH-A1, H-B2
15StorePlayerActor's metadata reads vitals.air, vitals.breathing, movement.sprinting and can-fly from the game mode, and Player.syncBreath's throttle gets a store counterpartstorePlayers.ts, playerBreath.tsC4d 5, C4gH-A1
16The record's maximum health is not applied, because the type decides it. Both are 20. No changenoneC4e 5none
17Copies. EYE_HEIGHT and eyePositionAt live in entity/Human.ts, with 9 importers. StorePlayer.setPortalCooldown repeats Entity.setPortalCooldown's clamp. The saturation clamp is done (C4d)new entity/playerEyes.ts, storePlayer.tsC4e 4H-S0
18Out of chunk saves and the despawn cycle. Done: ChunkSaveContext.ts:29 skips minecraft:player by type, and runDespawnCycle skips an entity with no spawn pool, which a spawned player never hasnoneC4b, C4e 7, C4gdone, H-B2 tests it
19Out of chunk unloads. World removes every store entity storeInChunk files in an unloading chunk (World.ts:3318), and markEntitiesInChunkChanged (:3571) marks a chunk changed for any of them, players includedWorld.tsfoundH-S0
20Ticking anywhere. The legacy loop exempts a player from the ticking-chunk check (World.ts:1153); EntityRuntime.tickEntities and the actor update (World.ts:1159) apply it to every store entityEntityRuntime.ts, World.tsfoundH-A1 (store), H-S0 (world)
21A player unit that throws disconnects its session instead of removing the entityEntityRuntime.ts, EntityStore.ts, live.tsC1c; missing from the H-C4 notesH-A1 (hook), H-B2 (wire)
22A player's blows run DamagePipeline: a mob's blow on a player (live.ts:385,395,442 start target.damage without awaiting), a player's swing, a PvP blow, the entityDamage hook and the grace period on one path, and applySonicBoom without a promiselive.ts, Combat.ts, legacyDamage.tsH-C3, C4bH-B2
23The PvP rule tests instanceof HumanCombat.ts:111foundH-S0
24Death and respawn through the store: kill keeps the player in dying, EntityRuntime.revive brings it back, and the respawn request reads the spawn point from the componentPlayer.onDeath, Player.respawn, RespawnRequests.ts, playerRespawn.tsB3b, C4bH-B2
25Delete MobEquipmentHandler's relay. StoreActors.sync sends the equipment, and armor and off-hand changes then reach viewers at once (#2965)MobEquipmentHandler.tsC4gH-B2
26Game mode, abilities, skin and the player list pass the actor to sendGameTypeFor, sendAbilities and playerListEntryOf. A skin change writes StorePlayer.skin. A game mode change sends the game type and abilities itself, since StoreActors.sync does not notice itPlayer.setGamemode, Player.enable, PlayerSkinHandler.ts, ResourcePackClientResponseHandler.tsC4gH-B2
27The replicator's own-client copies by session. Done by F3: World.viewerOf (072a0f5029), and viewers are sessions (World.getViewers, 9c4ec138a2)noneC4gdone
28Keyed services. Done: advancements and the enchantment seed take a key (c594bb33f3), and World.getPlayerData and lastPlayerRecord read the key and the world (93f0942ff9, 2130d83f79). Left: World.savePlayerData and playerRecordOf need a KeyedPlayer, because enchantmentSeedOf takes getServer() from a legacy playerWorld.ts:4183, playerRecord.ts:155C4e 3H-B3
29The legacy-player overloads kept beside the key forms: PlayerKeySource and KeyedPlayerSource, BossBar's methods, the ban check and PermissionManagerkeyedPlayer.ts, BossBar.ts, ban/, permission/F3, F4H-C2
30The chat recipient's receive finds its session by identityChatManager.ts, Player.tsB3bH-B3
31Player.restoreHealth, restoreFlying and Player as a PlayerRecordTarget go. StorePlayer is the record's one targetPlayer.ts, playerRecord.tsC4eH-D
32Players found by class: asPlayer (item/actorPort.ts:69), playerSessionOf (command/host/support.ts:181), the trade, filter view and swing in live.ts:852,914,934, and World.ts:1153,3160,3487,3543,3575,3805. Each finds the session by id (World.viewerOf) or tests the typethose filesF4, C3b, C4gH-S0
33WorldActor narrows to StoreActor. World's entity map, players map, getEntities and getEntity go, and so do EntityIndex's legacy half (addLegacy, refileLegacy, removeLegacy, legacyWithin) and Explosion's legacy queryWorldActor.ts, World.ts, entityIndex.ts, Explosion.ts, the 19 Entity importersC1b, C3bH-C1
34No array copy. EntityRuntime.all() spreads the store for the entity pass, the despawn cycle and curing. World.getActors copies and sorts for selectors, saves, the digest and the content adapter's at, near and countOfType, and shownActors concatenatesEntityRuntime.ts:717, World.ts:3660,3698slice 5 target, C1b, C3bH-C4 (all()), H-C1 (getActors)
35The replay digest's player branch (World.ts:3805) hashes a legacy player's slot counts and componentSnapshot. It moves to the store player, so digests of a world with a player change onceWorld.tsC4cH-B2, re-baselined in its own commit
36A world without an entity store. Seven getEntityRuntime?. sites and five hasEntityStore() fallbacks in World.ts keep a path for a world that runs the written entity classes. A world needs a store to hold its playersWorld.ts, legacyAdapter.ts:52foundH-C1
37ItemActorBody becomes a StoreActor subclass: portal travel through StoreActor.update and transferStoreEntity, AddItemActor through spawnTo, and merge, pickup and unload through the storeitemBodies.ts (deleted), storeActors.ts, live.ts, EntitySerializer.ts, World.ts:3152, PlayerSession.sendAddItemActor, DimensionTravel.transferH-C2, C3bH-A2
38GenericEntity is the load path for every stored actor. EntitySerializer.fromNBT builds one, World.createStoredEntities and the level snapshot (World.ts:761) hand it to the store, and an actor whose type no pack declares stays in the world as a live GenericEntityEntitySerializer.ts, World.ts:761,1712,3148, live.ts:1104, Entities.ts (writtenEntities)foundH-A2
39World.ITEM_ENTITY_LIMIT counts item bodiesWorld.ts:2927H-C2H-A2
40Delete Entity.ts, GenericEntity.ts, Human.ts, Player.ts, legacyAdapter.ts, Entities.ts's Human and its deprecated Player alias, Entity.restoreLoggedField and restoreLoggedSlot, testing/playerDouble.test.helper.ts, and Player.viewDistance, which nothing has written since F3those filesC4c, C4f, F3, F4H-B2, H-D
41The public API: the root Player export (index.ts:27), Entity through entity/index.ts's export *, Entities.Human and Entities.Player, and the "./*" subpath to each deleted module. Prismarine is 0.20.0, so the Changeset is minorindex.ts, entity/index.ts, .changeset/foundH-B2 (Entities), H-D
42Reword prose. 43 source files name Player without importing it; C4f counted 23each file's ownerC4fH-C1 to H-C4
43The slice 5 target as a proxy test beside noMirror.test.ts: no Entity, GenericEntity or Human class, no subclass of StoreActor but the store's own, and no copy from all()new entity/data/noLegacyEntity.test.tsslice 5 targetH-C4, gating at H-D

Three items are done (13, 18, 27) and three partly (9, 17, 28). Eight were found in the code: 2, 3, 19, 20, 23, 36, 38 and 41, and item 21 was in C1c's notes but no H-C4 note.

2. The sequence. Every step keeps the server working and lands on the program branch before the next one starts. "Unit tests" means pnpm exec vitest run --since-last-run and the Prismarine suite in full once. "The replay suite" is packages/client/src/SessionReplay.test.ts.

StepWorkGateRisks
H-S0 seamsItems 3, 7, 17, 19, 20 (world side), 23 and 32, and World.addPlayer and removePlayer, which call addEntity and removeEntity for now and which SetLocalPlayerAsInitializedHandler, PlayerSession.changeWorld and Player.disable call (item 6). No behavior changes. Afterwards only Player.ts, Human.ts, Entities.ts and the session directory name the player classUnit tests; the replay suite with unchanged digests; parity blocks.portals, protocol.dimension-change, gameplay.combat-advancements; client e2e spawn, rejoin, dimensions, death-respawn, attack-entity, item-drop-pickupA lookup by id that misses a player outside a world's player map. F3 made World.viewerOf answer for a player joined or not; a unit test covers the join window
Wave H-AH-A1, H-A2 and H-A3 side by side, in part 3. Live players are still legacyPer package, belowPer package
H-B1 Player stops being an entityPlayer holds a body instead of being one: a LegacyPlayerBody extends Human, which the world's maps, the index and the replicator hold, and Player delegates to it. Code that hands a player to entity code (a blow's attacker, the portal touch, the events, the triggers) passes the body. A refactor with no behavior changeUnit tests; the replay suite with unchanged digests; every parity family; the full client e2e suiteIdentity checks that compared the Player object itself, such as attacker === target and world.getEntity(id) === player
H-B2 the body is the store entityLegacyPlayerBody becomes the StorePlayerActor of a spawned store player. Items 1, 5 (store side), 6, 12 (wired), 14 (routed through Player's methods), 21 (wired), 22, 24, 25, 26 and 35. Deletes Human.ts with Entities.Human and Entities.Player, the player half of legacyAdapter.ts (logLegacySlot, logLegacyImpulse, the player fields), restoreLoggedField, restoreLoggedSlot and playerDouble. Player keeps no entity stateUnit tests; the replay suite, with the digest re-baseline in a commit of its own that says only player hashing changed; every parity family; the full client e2e suite, reporting spawn, rejoin, duplicate-login, movement, movement-fanout, movement-rotation, look-direction, jump, gait, teleport, teleport-while-dead, damage, fall-damage, player-knockback, death-respawn, spawnpoint-respawn, dimensions, ender-dragon, eating, mining-drops, villager-trade and attack-entity by name; the in-game checks the C4d, C4e and C4g notes list, with two vanilla clientsThe largest step. What differs by path today becomes one path: the grace period and entityDamage on a player, the breath numbers (C4d), the meal timing and the metadata. A player not yet spawned must be neither targetable nor shown (question 1)
H-B3 name the homesEach Player method moves its body to its home and leaves a one-line delegate. Client operations go to PlayerSession: completeSpawn, sendSettings, getJoinSpawn, sendInitialSpawnChunks, sendMessage, and the sends of setGamemode and setFlying. Simulation goes to PlayerSystems and EntityRuntime: setMining, beginConsuming, respawn, setSprinting, setSneaking, and the writes of setGamemode and setFlying. Items 28 and 30Unit tests; the replay suite with unchanged digests; client e2e spawn, chat, eating, mining-drops, teleport, death-respawn, dimensionsLow. The delegates keep every caller compiling
Wave H-CH-C1 to H-C4 side by side, in part 3. Each moves callers off Player, Entity and WorldActorPer package, belowA caller in another package's file; the rules below
H-D deleteItems 4, 31, 40 and 41, and what wave C left: Player.ts, Entity.ts, the rest of legacyAdapter.ts, LegacyPlayers, World.getPlayers (its callers use getViewers), the WorldActor alias, the root and Entities exports. Lower the barrel baseline (node scripts/check-barrels.mts --update). The noLegacyEntity test gates. A minor Changeset for @jsprismarine/prismarine. Update docs/data-driven/entities.md, this page's progress and server-rearchitecture.mdpnpm run build, typecheck, lint, lint:custom and test; the parity suite; the replay suite; the full client e2e suite, and pnpm run e2e:client:dist for the built packageA plugin-facing type change; question 2

3. Side by side. Two waves of packages, each package in its own worktree. No file is in two packages of one wave, and a file not listed belongs to no package of that wave. The rules are C4f's: narrow a parameter freely, narrow a return type or an exported member only when every reader is in the package, and leave the rest for the next serial step. Each package converts the tests beside its files and the tests part 4 gives it.

Wave H-A, after H-S0:

PackageItemsOwns
H-A1 the store player, complete2, 8, 9, 10, 11, 12 (the facts module), 14 (the system calls), 15, 20 (the store pass), 21 (the hook)entity/storePlayer.ts, entity/data/storePlayers.ts, entity/data/playerSystems.ts, entity/playerComponents.ts, entity/survivalPlayer.ts, entity/playerHunger.ts, playerBreath.ts, playerFall.ts, playerMeal.ts, playerMining.ts, playerRespawn.ts, entity/data/EntityRuntime.ts, entity/data/EntityStore.ts, world/playerRecord.ts, new entity/data/playerHostFacts.ts, testing/storePlayer.test.helper.ts. Gate: unit tests, PlayerPersistenceFormat.test.ts byte for byte, parity entities.behavior (player-health, player-survival). No live change, so no e2e
H-A2 the bodies that are not players37, 38, 39entity/data/itemBodies.ts, entity/data/storeActors.ts, new entity/data/itemActor.ts, entity/data/live.ts, entity/EntitySerializer.ts, entity/GenericEntity.ts, entity/Entities.ts, world/World.ts, world/Level.ts, world/portal/DimensionTravel.ts, network/PlayerSession.ts (sendAddItemActor only). Gate: unit tests; the replay suite, re-baselined in its own commit if the item actors hash in a new order; parity mapping.entities, entities.addactor, blocks.portals; client e2e item-drop-pickup, mining-drops, dimensions, natural-spawn-placement; in game, a stack thrown through a nether portal and a world reloaded with an actor no pack declares. After H-A2 the only subclass of Entity is the player's
H-A3 handler tests on portsnone, tests onlyThe 29 network/handler/*.test.ts files: each stub<Player> becomes a stub of the port its handler takes since F1 and F2. Gate: unit tests

H-A1 does not edit live.ts, which is H-A2's; H-B2 wires H-A1's facts module. H-A3 edits no source and no existing file under testing/.

Wave H-C, after H-B3:

PackageItemsOwns
H-C1 world and entity side33, 34 (getActors), 36, 42 for its filesworld/World.ts, world/WorldActor.ts, world/Level.ts, world/end/DragonFight.ts, world/providers/, entity/data/entityIndex.ts, entity/data/live.ts, entity/Explosion.ts, entity/Combat.ts, entity/Archery.ts, entity/EntitySerializer.ts, entity/worldContentAdapter.ts, command/CommandExecutor.ts, utils/ParseTargetSelector.ts, utils/ParseTildeCaretNotation.ts, events/entity/, advancement/triggers.ts, content/world/serverSource.ts. Gate: unit tests, the replay suite, every parity family, client e2e attack-entity, summon-entity, ender-dragon, natural-spawn-placement
H-C2 sessions, services and the plugin API5 (teardown), 29, the preparation for 4, 42 for its filesnetwork/PlayerSession.ts, network/ClientConnection.ts, network/BossBar.ts, network/NetworkWorldReplicator.ts, network/startGameData.ts, network/spawnPosition.ts, network/sessionPlayers.ts, SessionManager.ts, keyedPlayer.ts, events/player/ (11 files), command/ except CommandExecutor.ts, permission/, ban/, console/, advancement/progress.ts, advancement/raise.ts, enchantment/, item/, testing/playerSession.test.helper.ts, testing/blockTarget.test.helper.ts, testing/namedBlockTarget.test.helper.ts. Gate: unit tests, parity protocol.dimension-change, client e2e spawn, rejoin, duplicate-login, chat, dimensions, title
H-C3 handlers42 for its filesEvery source file under network/handler/: the 33 that call session.getPlayer() and the modules beside them, such as workstation.ts and tradeScreen.ts. Each handler reads state from the store player and calls the homes H-B3 named. Gate: unit tests, client e2e movement, jump, eating, mining-drops, block-placement, villager-trade, recipe-book, teleport, death-respawn
H-C4 copies, benchmarks and the proxy test34 (all()), 43entity/data/EntityRuntime.ts, entity/data/storeActors.ts, new entity/data/noLegacyEntity.test.ts, packages/prismarine/benchmarks/ (8 files), packages/parity/src/protocol.dimension-change.test.ts, the docs pages H-D updates. Gate: unit tests, the replay suite with unchanged digests (question 9)

H-C1 moves the World.getPlayers() callers it owns to getViewers(), and H-D deletes getPlayers. Only H-C2 edits PlayerSession.ts; a package that needs a new session member waits for H-D. H-C4 is the smallest, so it also takes the prose of item 42 in files no other package owns.

4. The test conversion. 126 files name a legacy class, build a legacy player or use a helper that does: 112 tests, 5 helpers under testing/, 8 benchmarks and 1 parity test. 117 of them import a legacy module. By use, and a file can have several: 66 stub Player (stub<Player>, StubMembers<Player>), 25 stub Entity, 20 call fakePlayer(), 15 build or subclass Entity, GenericEntity or Human, 8 use ItemActorBody, 7 call new Player, 6 build from Player.prototype, 6 test instanceof, and 7 only import a type.

GroupFilesWhen
H-A212: EntitySerializer, Explosion, live, blockBreak, worldDestruction, WorldDrops, WorldPersistence, WorldEntityIndex, WorldAutoSave, WorldReplayState, LevelDB, and benchmarks/entityOnlyChunkSave.tswave A
H-A329: every network/handler/*.test.tswave A
H-B214: testing/fakePlayer.test.helper.ts, testing/playerDouble.test.helper.ts (deleted), Player, PlayerChangeLog, FallDamage, PlayerPersistence, PlayerPersistenceFormat, SpawnPoint, WorldChangeTick, DimensionTravel, inventory/blockContainer, item/actorPort, item/interaction, enchantment/enchantingTable, and the Player.prototype doubles left in live and blockBreakH-B2
H-C127: advancement/triggers, chat/ConsoleText, entity/Armor, Combat, Damage.pipeline, Entity, damageTypes, data/actorMetadata, data/storePlayers, worldContentAdapter, utils/ParseTargetSelector, ParseTildeCaretNotation, and in world/ Level, RespawnSearch, WorldEntityAddition, WorldGeneratedEntities, WorldTickPhases, EngineWorld, PlayerRecords, WorldBreakBlock, WorldHitBlock, WorldPlayerChunkCounts, WorldPortalIndex, WorldViewers, cooking, spawners, end/DragonFightwave C
H-C236: SessionManager, advancement/progress, command/CommandManager, commandStartup, host/host, host/support, jsprismarineCommands, console/RemoteConsole, events/player/PlayerEvent, permission/PermissionManager, the three testing/ helpers it owns, and 23 in network/: BossBar, ClientConnection, MobEquipment, MovementBatching, NetworkWorldReplicatorEntities, the 14 PlayerSession* files, packet/AvailableCommandsPacket, sessionPlayers, spawnPosition, startGameDatawave C
H-C48: 7 benchmarks and protocol.dimension-changewave C

The helpers that have to exist first:

  • H-B2 rebuilds fakePlayer() over a store player in a real EntityRuntime (from storePlayerFixture) and keeps its return shape, so its 20 callers change only where they reach legacy-only members. It is the one helper every later group leans on.
  • H-B2 deletes withPlayerComponents. Its 6 callers build a store player instead.
  • H-A2 replaces new GenericEntity and extends Entity stand-ins with store entities from liveTestEntities (testing/entityServer.ts), and adds testing/storeActor.test.helper.ts only if that is not enough.
  • H-C2 moves sessionFor, sessionPlayerFields and sessionsWith to SessionPlayers<StorePlayer>, and H-D drops their Player defaults.
  • Entity.test.ts and the LegacyPlayers block of sessionPlayers.test.ts are deleted; Entity.test.ts's id overflow assertion moves to entityIds.ts's test in H-S0. Damage.pipeline.test.ts, PlayerChangeLog.test.ts and FallDamage.test.ts test behavior a player still has, so their assertions move onto a store player and each file goes only when every assertion has a home.

5. What C4h does not do.

  • Slice 6 (D1, D2): the handler shape and narrow context, Server out of handlers, synchronous input-lane handlers, the eight handlers that load chunks or search, RespawnHandler's search, and building the player before the join. H-C3 changes only what a handler reads the player through.
  • Slice 7 (D3, D4): replication from the log. StoreActors.sync's comparisons, the per-tick equipment comparison, the breath and motion sends, a stack network id after an undo, the status effect clock and actor record tags, block and block entity logging, and merchant offers.
  • Slice 8 (E1): saves at a tick boundary.
  • Slice 9 (E2 to E4): DimensionTravel as messages, worlds built outside ticks, the respawn search's and the dragon fight's in-tick chunk loads, and TickContext's AsyncLocalStorage. C4h keeps DimensionTravel's awaited transfer and removes only its class checks.
  • Slice 10: one chat listener and the event bus. Slice 11: explicit exports; H-D removes only the deleted modules' exports.
  • Bugs found earlier stay their own: #2961, #2968, #2969. #2965 closes with item 25.
  • H-A2 and H-C4 depend on no player work and can be named packages of their own if the program wants them out of C4h's commitment. Slice 5's target needs both.

6. Open questions.

  1. Where does a store player live before it spawns and between two worlds? Recommendation: StorePlayer outlives its entity. It is filed by an id drawn at the login from H-S0's allocator, holds the record's position, look and health while detached, and attaches an entity through EntityRuntime.restore(record, at, id) at SetLocalPlayerAsInitialized and on arrival. This matches today, where a joining player is in no world's entity map and no mob can target it, and it is ADR 0011's leave and enter. The other choice, an entity spawned at the login and hidden, needs every query to skip it.
  2. What replaces Player for callers and plugins? Recommendation: session.getPlayer() returns the StorePlayer, operations live where H-B3 puts them, and the ten player events drop getPlayer() for the getRuntimeId() and getIdentity() F4 added plus a new getSession(). The root Player export goes. Prismarine is below 1.0.0, so this is a minor Changeset.
  3. Does C4h fold ItemActorBody into StoreActor? Recommendation: yes, as H-A2. It extends Entity, so slice 5's target cannot pass while it exists. Two of C3b's four paths already have a store home: StoreActor.update runs the portal clock, and transferStoreEntity moves a store entity between stores.
  4. What happens to a saved actor whose type no pack declares? Today it loads as a GenericEntity and is shown with its identifier. Without the Bedrock pack every saved mob is such an actor. Recommendation: an inert built-in store definition, as H-C2 added for items, that keeps the identifier, the record and persistentNbt, shows the actor and runs nothing. Dropping the actors or keeping their NBT unseen would make a world loaded without the pack look empty.
  5. Does the switch split into H-B1 and H-B2? Recommendation: yes. H-B1 changes only types and holds the digests, so H-B2's diff is behavior alone and its digest change has one cause.
  6. Where does the game mode live? Recommendation: a logged component field, which SurvivalPlayer.vulnerable reads and the store undoes, replacing the adapter's gamemode field. StorePlayer.gamemode becomes an accessor over it.
  7. Which ground state stays? Recommendation: the body's on_ground condition, which applyClientMovement writes. Delete movement.onGround; the digest change lands in H-B2's re-baseline.
  8. Which clock does a meal count on? Recommendation: the store's tick, which the change log and the entity pass already count by. The level clock moves backwards under /time set.
  9. How does the entity pass walk the store without a copy? Recommendation: iterate the store's map in insertion order and skip an entity added after the pass began, by a sequence number the store stamps on insertion. The copy left out the same entities, so the digests do not change.
  10. What replaces World.getActors' sorted copy? Recommendation: the content adapter's at, near and countOfType query the index, which runs inside the tick. Selectors and the save context iterate the store and sort only what they return, and the digest sorts, as it runs outside the tick.

H-S0, the seams, left these for the later steps. Items 3, 7, 17, 19, 20 (world side), 23 and 32 are done, and so is the entry point of item 6.

  • One id pool: entityIds in entity/entityIds.ts, an EntityIdAllocator. The legacy constructor and LiveEntityRuntime's nextId draw from it, and Entity.runtimeIdCount is gone. The overflow guard the legacy constructor had now covers the store's ids too. Entity.test.ts's overflow assertion moved to entityIds.test.ts.
  • EYE_HEIGHT and eyePositionAt live in entity/playerEyes.ts. The portal cooldown clamp had a third copy, in StoreActor; portalCooldownOf in PortalContact.ts is the one home.
  • A player enters a world through World.addPlayer and leaves through World.removePlayer. addPlayer files the player in the player map and then calls addEntity, which no longer files anything there; removePlayer calls removeEntity, which still deletes the id from the player map, so the content host's remove behaves as before. SetLocalPlayerAsInitializedHandler, PlayerSession.changeWorld and Player.disable call them. H-B2 points both at the store.
  • Two rules replaced the class tests. Where the code needs the player's state, it finds the session by entity id with World.viewerOf and reads session.getPlayer(): DimensionTravel's mid-change check, transfer and End exit, the command host's playerSessionOf, the item actor port, and the live host's trade screen, filter view and swing relay. Where it needs only to know that an entity is a player, it tests the type against PLAYER_IDENTIFIER: the PvP rule, nether portal creation, the End arrival height, and World's tick exemption, attribute flush and chunk membership. The replay digest reads the player from the world's player map by id. The live host's filter view reads a structural FilteredPlayer.
  • The store side of items 19 and 20: a store entity of type minecraft:player ticks outside the ticking chunks, stays in the store when its chunk unloads, and does not make markEntitiesInChunkChanged mark the chunk. No live player is a store entity yet, so nothing changed at run time.
  • Not closed: the entity index's chunkChanged callback (World.storeEntityChunkChanged, wired in live.ts) marks a chunk changed when any store entity crosses into it, a store player included. A changed chunk is saved before it can unload, and the save skips players, so the cost is an extra save. It gets only the chunk coordinates, so the fix belongs beside the index: H-B2, which spawns store players and tests that they stay out of chunk saves.
  • viewerOf answers only for a player in the world's player map, which the plan named as the risk. Every production caller reaches a player through that world's portal pass, entity update, entity lookup or tick, so each one is in the map. The test doubles that built a player outside it now put it there or answer viewerOf: DimensionTravel, WorldTickPhases, item/interaction, item/actorPort, world/blockBreak, advancement/triggers, command/host/host, CommandManager, the trade double in live, fakePlayer, the parity family protocol.dimension-change, and the tests that called addEntity with a player. WorldViewers.test.ts checks that a player added through addPlayer is found before entityAdded runs and not after removePlayer.
  • One edge case remains, as with F3's lookups: if Player.disable throws before removePlayer, ClientConnection still runs PlayerSession.disable. The player then stays in its world's map with a forgotten session, and session.getPlayer() throws where instanceof Player passed.
  • What still names the class. As a value: Player.ts, Entities.ts, the root export in index.ts (H-D) and LoginHandler.ts, which builds the player (H-B2). As a type only: the session directory (sessionPlayers.ts, createConnectedPlayer.ts, PlayerSession.ts, SessionManager.ts), the eleven events/player classes (H-C2), InventoryTransactionHandler, ItemStackRequestHandler and workstation.ts (H-C3), and World.ts's player map, getPlayers, breakBlock and addPlayer (H-C1, H-D). Nothing outside tests uses instanceof Player or instanceof Human.
  • Copies found on the way, for item 42's packages: the literal 'minecraft:player' is repeated in fourteen source files beside PLAYER_IDENTIFIER (among them DeathMessages.ts, worldContentAdapter.ts, mobLoot.ts, advancement/triggers.ts, command/host/support.ts, goalWorld.ts, item/interaction.ts, ParseTargetSelector.ts and ChunkSaveContext.ts), and Explosion.ts and entity/data/explosion.ts each define an eye height fraction of 0.85 that ENTITY_EYE_HEIGHT_FRACTION in entity/data/position.ts already holds.
  • Checks at program head d2c8ec355f: the Prismarine suite with JSP_CONTENT_VANILLA=1, the replay suite unchanged, the whole parity suite, and client e2e spawn, rejoin, dimensions, death-respawn, attack-entity and item-drop-pickup pass.

H-A1, the store player, left these for H-B2. Items 2, 8, 9, 10, 12 (the facts module), 14 (the system calls), 15 (the projection), 20 (the store pass) and 21 (the hook) are done. Item 11 is done for the store player and leaves one field for H-B2.

  • A StorePlayer outlives its entity (question 1). It is built from an id and the identity, new StorePlayer(id, identity), and holds its position, look and health while it is in no store, health in its attribute table's entry. enterStore(runtime, players, player) files it and gives it an entity under its id: EntityRuntime.spawnPlayer the first time, and EntityRuntime.restore(record, at, id, prepare) from the record its last entity left with after that. The player attaches in the prepare step, before the entity enters, so what it held is part of the entering entry and the load appends nothing else. leaveStore(runtime, player, reason) keeps the record, the tags and the status effects and removes the entity, and the player stays filed. StorePlayers.delete is still the server leave. spawnStorePlayer builds, fills and enters in one call, drawing the id from EntityRuntime.nextEntityId; H-B2's login draws it from entityIds instead and enters at SetLocalPlayerAsInitialized.
  • StorePlayerActor takes the entity beside the player, and StorePlayers.actorOf builds one only for the entity the player is attached to.
  • Logging (items 8 and 9). The player owns one PlayerComponents, one HumanInventory and now a CraftingInput from the login on. PlayerSystems.attach(entity, parts) takes the components and two writers, and returns the PlayerLog the player writes through while attached: component fields and inventory.handSlot through EntityStore.written, and slots through EntityStore.writtenSlot once the entity has entered. EntityRuntime.writeInverse undoes an inventory-slot entry and inventory.handSlot through the player's writers, which is what Human.restoreLoggedSlot and restoreLoggedField do for a legacy player. StorePlayer.restoreSlot repeats Human.restoreLoggedSlot's body, because Human.ts was outside this package; the copy goes when H-B2 deletes Human.ts.
  • Game mode (item 10, question 6) is the component field abilities.gamemode, and StorePlayer.gamemode is an accessor over it. A fresh player's components start in survival, where the old field started at WORLD_DEFAULT, so a raw minecraft:player spawned in a test still starves. survivalReaches (survivalPlayer.ts) and mayFlyIn (playerComponents.ts) are the rules' homes; playerRecord.ts reads mayFlyIn. Player keeps its own copies of both and its gamemode field, which the legacy adapter logs as gamemode. The legacy player never writes abilities.gamemode, so its componentSnapshot gained one constant entry and the absolute digest of a world with a player changed. The replay suite compares two replays and passes unchanged.
  • Ground (item 11, question 7). A store player's ground is its body's on_ground condition, which StorePlayer.isOnGround reads, and nothing reads or writes movement.onGround for one. Deleting the field needs Player.ts, which reads it, so H-B2 deletes it, with the digest change in its re-baseline.
  • The meal (item 12, question 8) counts on EntityStore.tick, the change log's tick, which the systems reach through the tick port. storePlayerHostFacts(players, finish) (entity/data/playerHostFacts.ts) answers heldItemOf and finishMeal from StorePlayers. Finishing is handed in, because consumeHeldItem takes an InteractingPlayer, which a store player does not satisfy; H-B2 supplies it when it spreads the facts into the live host.
  • The system calls (item 14): PlayerSystems.setSprinting, setSneaking and jumped, beside the existing setMining, beginMeal and cancelMeal; EntityRuntime.teleportPlayer, the server's move, which teleports the body and ends the fall; and moveStorePlayer, which calls applyClientMovement and writes the head yaw. Nothing calls them yet: H-B2 routes the handlers' Player methods to them.
  • Metadata (item 15). StorePlayerActor.showPlayerState writes air, breathing, sprinting and can-fly from the components into the actor's metadata and answers whether a send is due, with the legacy throttle, now breathWorthSending in playerBreath.ts. The constructor calls it, so the first AddPlayer carries them. StoreActors.sync does not call it, because storeActors.ts is H-A2's. H-B2 calls it after the component projection, which writes its own can-fly flag, and sends the metadata when it answers true. Player.syncBreath keeps its copy of the throttle until the class goes.
  • The store pass (item 20) ticks a minecraft:player wherever it stands, as the world's loops do.
  • The hook (item 21) is EntityRuntime.onPlayerFailed, a settable field like rides.onChange, because the fixture in entity/data/testing.ts passes no new options and that file was outside this package. After ENTITY_FAILURE_LIMIT failures in a row the runtime calls it in place of removing a player. It fires again on every later failure while the player stays in the store, so H-B2's disconnect has to tolerate a second call. Left unset, the player is removed as a mob is.
  • What is not done: no live code builds, enters or ticks a store player, and live.ts is untouched. H-B2 wires onPlayerFailed, storePlayerHostFacts, showPlayerState, the system calls and the login, and deletes movement.onGround, Player.syncBreath's throttle and the copies of the game mode rules.
  • Checks at H-A1's head: the Prismarine suite with JSP_CONTENT_VANILLA=1 (428 files), PlayerPersistenceFormat.test.ts byte for byte, the whole parity suite with entities.behavior (player-health, player-survival), SessionReplay.test.ts, pnpm run typecheck, pnpm run lint and pnpm run lint:custom pass.

H-A2, the bodies that are not players, left these for the later steps. Items 37, 38 and 39 are done, and so are questions 3 and 4.

  • A dropped stack (item 37) has an ItemActor (entity/data/itemActor.ts), a StoreActor that StoreActors builds through a new builtInActor option beside playerActor. It keeps what its legacy body sent: an AddItemActor from spawnTo, a tracking range of six chunks, never on the ground, no metadata (StoreActor.projectMetadata answers an empty projection) and the default attributes, never sent (StoreActor.showHealth does nothing for it). A blow goes to DroppedItems.hurt, and the entityDamage hook and damageCommitted skip an item actor, because a stack's body raised no plugin event or trigger.
  • Its portal clock runs in StoreActor.update. DimensionTravel.transferStoreEntity moves it with LiveEntityRuntime.adoptItem(actor, at, cooldown), which spawns the stack in the destination from savedItem, as the legacy transfer did, rather than from the data entity record, so the motion carries over and the stack's saved NBT gains no record. persist and savedRecords skip item and inert actors for the same reason. A stack leaves an unloading chunk with the chunk's other store entities. ItemBodies, LiveEntityRuntime.unload and removeBody are gone, and so is World.addEntity's adoption of a body.
  • World.ITEM_ENTITY_LIMIT (item 39) counts StoreActors.droppedItemCount, kept as actors are added and removed.
  • EntitySerializer.fromNBT (item 38) reads a plain StoredActor and builds no entity. World.restoreDataEntity takes it to the item path, to the record or restoreExisting, and then to LiveEntityRuntime.restoreInert (question 4). The inert type is the entity overlay's jsprismarine:inert_actor, which has health, the twenty a legacy body defaulted to, and nothing else. Its actor is an InertActor (storeActors.ts) that reports the saved identifier through getType and getBox; the live runtime files the identifier by entity id in the spawn's prepare step, before the actor is built. It keeps the actor record tags, a record of an undeclared type among them, the tags, the status effects, the portal cooldown and a TNT's fuse, and a save writes it back unchanged. Through a portal it is restored inert in the destination; its health does not carry over, where a legacy GenericEntity kept its own.
  • The level snapshot builds a StoredActor and skips minecraft:player, which Level.entitySnapshots never writes. A snapshot of a dropped stack, which has no stack, no longer becomes a body in the world.
  • The player body is the one subclass of Entity in the server's source, which noMirror.test.ts now checks. GenericEntity moved to testing/genericEntity.test.helper.ts for the tests that still hand a legacy mob to code that takes an Entity: Combat, ParseTargetSelector, WorldChangeTick, DimensionTravel's legacy mobs, WorldEntityIndex's legacy-half tests, and, of H-A2's own group, Explosion, WorldAutoSave, WorldReplayState and benchmarks/entityOnlyChunkSave.ts. Each goes when H-C1 or H-B2 narrows the code it tests. Entities.writtenEntities is gone; the prose of SpawnableEntityClass in Entity.ts still names it, for H-D.
  • Outside the file list, for the inert type and the test conversion: the overlay file, content/testing.ts's built-in list, content/packStack.test.ts, which allows the one non-minecraft: type, the prose of content/README.md and docs/data-driven/entities.md, inventory/blockContainer.test.ts, world/WorldBreakBlock.test.ts, and two helpers, testing/savedActor.test.helper.ts and testing/storeWorld.test.helper.ts.
  • No digest changed. An item actor hashes the legacy body's attributes, and World.getActors sorts by runtime id, so the stacks hash in the same order. The replay suite needed no re-baseline.
  • Found: /summon ignores is_summonable, so it also spawns jsprismarine:inert_actor, as an invisible entity (#2975), and a saved actor without a record comes back with its health capped at twenty, which restoreExisting keeps from the legacy body (#2976).
  • In-game checks for the orchestrator's combined pass: a stack thrown into a nether portal arrives in the Nether with its count and can be picked up there; and a world saved with mobs, loaded by a server started without the Bedrock pack, shows each mob where it stood, standing still, and after a restart with the pack the mobs come back alive.
  • Checks at H-A2's head, on program head 13f30f1407: the Prismarine suite with JSP_CONTENT_VANILLA=1 (429 files), SessionReplay.test.ts, the whole parity suite with mapping.entities, entities.addactor and blocks.portals, the entities package, client e2e item-drop-pickup, mining-drops, dimensions and natural-spawn-placement, pnpm run typecheck, pnpm run lint and pnpm run lint:custom pass.

H-B1, the player body, left these for H-B2. No behavior changed and no digest moved.

  • The shape. Player no longer extends Human. It builds a LegacyPlayerBody (entity/LegacyPlayerBody.ts) in its constructor and returns it from Player.getBody(). The body is a Human that implements ShownPlayer and holds public readonly player, as StorePlayerActor does, and answers the profile members (getUUID, xuid, skin, device, gamemode, isOp, isFlying, getName, getFormattedUsername) from the player. H-B2's swap is Player.body and getBody()'s return type, from the body to the spawned store player's actor and its StorePlayer.
  • What holds the body: the world's entity map (World.addPlayer and removePlayer pass it to addEntity and removeEntity), the entity index, the chunk membership writes, the replicator, the change log's legacyStoreOf and the legacy inverse writer. The world's player map still holds the Player, because getPlayers and viewerOf hand out players and sessions; H-D deletes getPlayers.
  • What the body keeps: the entity state, the inventory and crafting grid, and the PlayerComponents with the survival view (getComponents, getSurvival). Player reads them through two private accessors, components and survival, which H-B2 points at the StorePlayer. The body's overrides are the player's entity rules: the damage gate, knockback as an impulse (Player.applyImpulse), the hunger effects, the logged attributes, component undo, the portal delays and the held cooldown, the tracking range and interval, spawnTo and isOnGround. Each has a store counterpart already, named in items 9, 14, 15 and 22, except processPortalCooldown's hold during a dimension change, which StoreActor's portal clock does not have yet.
  • The tick and the death go back to the player. The world ticks the body; LegacyPlayerBody.update runs Entity.update and then Player.updatePlayer, the session, vitals, meal, void, mining, attribute sync and trigger part. Player.update runs the body's. The body's onDeath calls Player.die(source, dieAsEntity), which runs Entity.onDeath at the point the old super.onDeath call stood. Once the store pass ticks the player, something has to call updatePlayer, or its parts move to PlayerSystems and the session, as H-B3 plans.
  • Player delegates what its callers read: the id, type, position, world, server, box, inventory, crafting grid, health, setHealth, damage, heal, feet and eye height, equipment, tags, status effects, portal cooldown, and metadata, attributes, pitch, yaw and headYaw as accessors. It does not implement spawnTo or getTrackingRange, so it is no ShownActor, and the compiler finds every show. These delegates are the second half of H-B2's swap.
  • Where source passes the body: a swing (meleeAttack) and an arrow (loose) in InventoryTransactionHandler, the move broadcasts and resets in MovePlayerHandler, ActorEventHandler's relay, RequestPermissionsHandler's abilities, the death message and animation, the change log writes in Player, the content adapter's players, the command host's @a, @p and @r candidates, and every show in Player (moves, abilities, game type, status effects). The session shows its own player through PlayerSession.shown(), which reads getPlayer().getBody(); OwnPlayer lost its ShownPlayer half. item/actorPort.ts hands interactWithEntity the entity the world answers for the source rather than the player. Of these, addEntity, removeEntity, meleeAttack, loose and the logLegacy* writers take a nominal Entity and need retyping in H-B2; the rest take ports StorePlayerActor satisfies.
  • The identity checks. Every comparison entity code makes now compares bodies, because the world answers the body for the player's id and every caller above passes it: legacyStoreOf's world.getEntity(id) !== entity, canHarm's attacker === target, deathMessage's killer !== victim, World.entityRelocated's map check and EntityIndex's. Two comparisons stay between players, because both sides come from the session manager or getPlayers: Player.sendSettings' target !== this and the shared-container check in ItemStackRequestHandler. entity/LegacyPlayerBody.test.ts covers the world's answer, a swing at the player's own id and a self-inflicted death; WorldViewers.test.ts and PlayerChangeLog.test.ts now assert the body where they asserted the player, and the change log test's world double answered the player, which the identity check would have refused.
  • Unchecked narrowing. advancement/triggers.ts narrowed an actor of type minecraft:player to AdvancementPlayer with a type guard, which a body would not satisfy. It now checks the type and whether anything listens, then finds the player through World.viewerOf by entity id, which also serves a store player. ParseTargetSelector's guard reads only gamemode, and the inverse writer's 'gamemode' in entity writes through the body's gamemode setter.
  • The plugin surface. entityDamage and entityDeath carry the body for a player; the player events still carry the Player. The Changeset (player-holds-a-body.md, minor) says so and lists what moved to the body.
  • Test doubles. playerOnPrototype(members, body) in testing/playerDouble.test.helper.ts builds a prototype player with a prototype body, puts metadata, attributes and the rotation on the body, and withPlayerComponents now writes the body's components. 16 test files call it; H-B2 deletes the file and its callers build store players. stubPlayerPort's doubles answer getBody with a plain object of the same members. A test that hands a player to entity code passes getBody(), and a double the world holds is a stubOf(LegacyPlayerBody, ...).
  • Checks at H-B1's head, on program head baea11e568: the Prismarine suite with JSP_CONTENT_VANILLA=1 (430 files), SessionReplay.test.ts with no baseline touched, the whole parity suite (53 files), all 43 client e2e scenarios in fifteen batches with none failing, pnpm run typecheck, pnpm run lint and pnpm run lint:custom pass. No adjacent bug was confirmed.

H-B2, the body is the store entity, left these for H-B3. Items 1, 5 (store side), 6, 12, 14 (through Player's methods), 21, 22, 24, 25, 26 and 35 are done, and so is the regression test item 18 asked for.

  • The shape. A Player holds a StorePlayer from its constructor on, under an id from entityIds, and keeps no entity state. It keeps three references the store player does not: the world it is in or about to enter, the server, and a DetachedPlayerView. World.addPlayer files the player in the world's player map and calls enterStore with Server.getStorePlayers() (EntityRuntimes.players). World.removePlayer takes it out of the map first and then calls leaveStore. Player.disable calls StorePlayers.delete. A dimension change and a respawn into another dimension are a leave and an enter with the same id. LegacyPlayerBody is deleted.
  • Two accessors replace the body. Player.getActor() returns the StorePlayerActor while the entity is in the store of the player's world, and undefined before the spawn and between two worlds. Entity code takes the actor: a swing, an arrow, the content adapter's players, the command selectors and ActorEventHandler. Player.getBody() returns the actor or the detached view, which is a ShownPlayer over the store player that the session shows its own player through before the spawn. Question 1 holds: no world holds a player that has not spawned, so nothing targets or shows it. entity/playerActor.test.ts checks that, and that Player.damage does nothing to such a player.
  • One damage path (item 22). A blow on a player runs DamagePipeline through its actor. intentOfLegacyBlow gives a player a mob's rules, since the shipped player is of the player family alone. A new game_mode guard (PlayerSystems.reaches) turns aside every blow in creative but the void and /kill, and every blow in spectator. That is LegacyPlayerBody.damage's rule. One change: /kill on a spectator, which the legacy setHealth reached, is now refused. A mob's bite, area attack and projectile hit a store player as a store target, so live.ts's hurt, hurtByProjectile and applySonicBoom no longer meet a player. The live host reads a player's armor from its inventory. The pipeline spends a player's absorption from attributes.absorption, which its entity has no attribute for. A shove on a player logs the impulse occurrence and the new host fact playerImpulse sends it to the client.
  • Death and respawn (item 24). The store keeps a dead player dying. damageCommitted in live.ts calls Player.die(source) after the entityDeath event and the kill triggers, for the death message, the respawn request and the death screen. Player.respawn calls EntityRuntime.revive, which now also revives a player restored with no health into another store on its way to a spawn point in another dimension.
  • The tick. StorePlayerActor.update runs the actor's part and then Player.updatePlayer, which is now the session, the copy of the entity's health into the client's attribute table, the attribute sync, the advancement check and the time. Breath, hunger, the void, the meal on the store's tick and the crack animation run in PlayerSystems. A player keeps its one actor update a tick in StorePlayer.claimUpdate, because a dimension change mid-tick gives it a new actor in a world the level ticks later. The client sees a health change one tick after the store makes it.
  • The inputs (item 14). setMining, beginConsuming, cancelConsuming, setSprinting, setSneaking, addJumpExhaustion and addExhaustion call the systems. A client move goes through moveStorePlayer, setOnGround reports a move to where the player stands, and a server move goes through EntityRuntime.teleportPlayer. Every move broadcasts the actor, so a head-only turn still reaches viewers. Player.isSprinting() replaces reading metadata.sprinting.
  • Metadata. StoreActors.sync calls showPlayerState after the projection through a StoreActor method a plain actor answers false to. setGamemode writes abilities.gamemode, calls showPlayerState and sends the metadata at once. The client's own metadata is the actor's while the player is in a store and the store player's metadata while it is in none. Player.syncBreath, its throttle copy, Player's game mode rule copies and movement.onGround are gone.
  • Equipment (item 25). MobEquipmentHandler only moves the hand. NetworkWorldReplicator.entityEquipmentChanged no longer sends the owner a copy, which named hotbar slot zero and would have moved the client's selection.
  • Scans that read the store and then the host's players (brainWorld.livingNear, goalWorld.mobs, the area attack, the entity sensor, the projectile hit) skip a client-owned store entity, so they meet a player once, through the view that knows its game mode. Before this, a piglin and a warden targeted a creative player in live.test.ts. A store blast takes its candidates from the index, which now holds the player, so it hits a player through the store. The entity index reports no chunk for a player's move, which closes the gap H-S0 left.
  • A pack stack with no minecraft:player file gets FALLBACK_PLAYER_FILE (playerOverlay.ts), so a server without the Bedrock pack still spawns players, and the fixture content has a player. A portal arrival's offset is now worked out over the player's 0.6 box where the fixture's default box was a block wide.
  • Deleted: Human.ts, Entities.ts with Entities.Human and Entities.Player and the root Entities export, the player half of legacyAdapter.ts (logLegacySlot, logLegacyImpulse, the player fields and the inventory-slot undo), Entity.restoreLoggedField and restoreLoggedSlot, testing/playerDouble.test.helper.ts and withPlayerComponents. Nothing subclasses Entity in source, which noMirror.test.ts now asserts. The barrel baseline lost one entry.
  • The digest (item 35). The player branch reads the StorePlayerActor's store player, and the component snapshot lost movement.onGround, in a commit of its own. The replay suite compares two replays and passed unchanged.
  • Test doubles. fakePlayer() enters a store player into a real LiveEntityRuntime of the fixture content and returns live and clock beside its old fields; its world's removePlayer leaves the store. playerOnPrototype(members, { runtimeId, position, actor }) in testing/storePlayer.test.helper.ts builds a prototype player over a real StorePlayer, and its actor is a StorePlayerActor double. stubPlayerPort answers getActor. Tests of rules that moved to PlayerSystems (breath, hunger over time, meals, crack animation, the void, the walking cost) lost their Player.test.ts copies, and FallDamage.test.ts runs a store player. Level.test.ts's cross-world tick case moved to storePlayers.test.ts.

What H-B3 must know:

  • The homes are still on Player. Most simulation methods already call PlayerSystems or EntityRuntime through Player.placed(), so moving them is moving one call each. The sends (completeSpawn, sendSettings, setGamemode's sends, setFlying's, the death screen in die) are the session's.
  • Player keeps world and server, which a StorePlayer has no way to answer. Either the session keeps them, or the store player learns its runtime.
  • The session directory is still LegacyPlayers of Player (item 4, H-D), and PlayerSession.shown() reads getBody().
  • ParseTargetSelector and the getEntity/getEntities half of World still type Entity. Nothing live is one, so H-C1 can narrow them freely.
  • Player.getPosition() builds a Position over the player's world on every call. A caller in a loop should read the store player.
  • Not done: no parity family covers the game_mode guard, because it keeps the legacy rule, and the spectator half of Java's answer for the void was not confirmed against Java.

In-game checks for the orchestrator's combined pass, with two vanilla clients A and B on one server:

  1. A joins. B joins and sees A appear with its skin and name in the world and in the player list.
  2. A walks, sprints, jumps, sneaks and turns its head only. B sees each, the head turn included.
  3. A changes the held hotbar slot, puts on a helmet and holds a shield in the off hand. B sees each change at once. A's own hotbar selection does not jump to slot one.
  4. A switches to creative. B sees the game mode change. A is not attacked by a zombie or a piglin, and arrows pass it. A switches back to survival and the zombie attacks.
  5. A eats a golden apple, then takes a zombie's bite. The yellow hearts go first.
  6. A sprints until the food bar drops, swims under water until the bubbles go and drowns, and B sees the death animation. A respawns with full food and air.
  7. A falls eleven blocks onto stone and onto a hay bale. Only the stone costs health.
  8. A mines a block slowly while B breaks it. A's cracks stop.
  9. A walks through a nether portal and back. B sees A leave and come back. A's cooldown holds while its loading screen is up.
  10. A sets a bed in the Overworld, goes to the Nether and dies there. A respawns at the bed.
  11. A leaves. B sees A leave the world and the player list. A logs back in where it left, with its inventory and health.
  • Checks at H-B2's head, 8b8e0e51f7 on program head d4c6a9a1f2: the Prismarine suite with JSP_CONTENT_VANILLA=1 (431 files, 4348 tests), PlayerPersistenceFormat.test.ts byte for byte, SessionReplay.test.ts, the whole parity suite (53 files), all 41 client e2e scenarios in fourteen batches with none failing or flaky, pnpm run typecheck, pnpm run lint and pnpm run lint:custom pass.

H-B3, the homes, left these for waves H-C and H-D. Items 28 and 30 are done, and every method the step named has its home. No behavior changed and no digest moved.

  • The client operations are the session's. PlayerSession.completeSpawn, announceSettings (what Player.sendSettings sends, each half to its audience), getJoinSpawn and setJoinSpawn, sendInitialSpawnChunks, sendDeathScreen (the Respawn and DeathInfo a death sends once the respawn search answers), and the two halves of a game mode change's sends, sendGameTypeChange and sendAbilityChange. The session's own join and world change call sendInitialSpawnChunks on themselves, so OwnPlayer lost it. announceSettings fans the abilities out over SessionManager.getSessions, which lists the sessions of the players getAllPlayers did, in the same order. Player.sendMessage was already one call into PlayerSession.sendMessage; it only turns its positional arguments into the delivery, so it moved nothing.
  • The simulation is the entity runtime's. EntityRuntime takes a store player's inputs: setPlayerMining, beginPlayerMeal, cancelPlayerMeal, setPlayerSprinting, setPlayerSneaking, setPlayerGamemode, setPlayerFlying, exhaustPlayer, playerJumped and revivePlayer. Each takes a PlayerInputTarget (the store player's entity and getComponents), writes through PlayerSystems while entityOfPlayer finds the entity in this store, and onto the components, or not at all, while it is in none. That is what Player.placed() decided, and placed() now reads entityOfPlayer. PlayerSystems gained setGamemode and setFlying. The rules stay in PlayerSystems; the runtime methods only choose between the two branches.
  • What stays on Player besides one-line delegates: the plugin events and the guards before them (setGamemode, setFlying and setSprinting post their events, setSneaking and setSprinting skip a write that changes nothing), and the order of the parts where one method spans both homes. setGamemode is the event, stopMining, setPlayerGamemode, sendGameTypeChange, setFlying(false) for a mode that may not fly, then sendAbilityChange. respawn is revivePlayer, the own-health copy, showPlayerState, getRespawnPoint and setPlayerPosition, then the session's sendRespawned and releaseChangeHeldOverDeath. The revival has to come before the respawn search's wait, so the two cannot become one runtime call without changing when the player is alive. die keeps the death message, the respawn request and the death animation and chat line; its death screen is sendDeathScreen. Player also still holds world, server, the detached view, the address, the permissions, the timer, connected and the chat recipient, and enable, disable, getRespawnPoint, forgetLostSpawnPoint, setPlayerPosition, setOnGround, updatePlayer and the item-use clock.
  • Item 28. playerRecordOf(player, keyed, dimension) takes a KeyedPlayer, and World.savePlayerData takes any PlayerRecordSource with a name and an XUID, a StorePlayer among them, building the key and taking the server from the world. LivePlayerRecordSource is gone. PlayerPersistenceFormat.test.ts now writes the store player's record through World.savePlayerData, byte for byte against the fixtures.
  • Item 30. chatRecipientOf({ runtimeId, identity }, server) in chat/ChatManager.ts builds the recipient from what a session holds: the key and name from the identity, the operator check from the permission list by UUID, and each line sent to SessionManager.sessionOf(runtimeId). It matches by entity id rather than by name or XUID, because a replaced login can briefly leave two sessions of one account connected. A line for a player who has left reaches nobody, where getNetworkSession() asserted. Player builds its recipient with it, so H-C2 or H-D can build it from the session.
  • For H-C3: a handler that called one of these methods on session.getPlayer() calls the home instead: the session's method on session, or session.getWorld().dataEntities().runtime.setPlayerX(storePlayer, ...) for an input. The runtime methods read world.dataEntities() even for a player in no store, which builds the dimension's content adapter if nothing had; a live player's world always has one by then. A handler that posts a player event keeps calling the Player method until H-C2 settles what the events carry. fakePlayer's session manager now answers getSessions.
  • For H-D: Player.getJoinSpawn and the join spawn writes in Player.enable go when enable moves; the session field is already the home. sendGameTypeChange and sendAbilityChange read this.getPlayer().getActor() and getWorld(), which H-D answers from the store player and the session.
  • Checks at H-B3's head, on program head 3426f22bdb: the Prismarine suite with JSP_CONTENT_VANILLA=1 (431 files, 4355 tests), SessionReplay.test.ts with no baseline touched, the whole parity suite (53 files), client e2e spawn, chat, eating, mining-drops, teleport, death-respawn and dimensions in three batches, pnpm run typecheck, pnpm run lint and pnpm run lint:custom pass. No adjacent bug was confirmed.

H-C3, the handlers, left these for H-D. Item 42 is done for network/handler/, and no handler's behavior changed.

  • The shape. playerPorts.ts holds two helpers and no ports: storePlayerOf(session) is the session's StorePlayer, session.getPlayer().getStorePlayer() for now, and playerInputsOf(session) is session.getWorld().dataEntities().runtime. KickedPlayer, PlacedPlayer, InventoryHolder, CraftingHolder and FlyingPlayer are gone, and so are the one-handler ports JoiningPlayer, SpawningPlayer, MovedPlayer, RespawningPlayer, ActingPlayer and ReskinnedPlayer. GamemodePlayer stays, because SetPlayerGameTypeHandler reads only what the permission check and setGamemode take. H-D's switch in the handlers is the body of storePlayerOf, which becomes session.getPlayer().
  • What a handler reads of the store player: the inventory, the crafting grid, the eyes, the look, the ground state, the health, the game mode, the flight, the skin and the components (mining.block, movement.sleeping, movement.sprinting, vitals.fallDistance and the experience a trade pays). The world is the session's everywhere, and so are the kick, the join spawn, announceSettings and completeSpawn. The inputs go to the runtime: setPlayerMining, setPlayerSneaking, playerJumped, exhaustPlayer, beginPlayerMeal and cancelPlayerMeal. movement.sleeping has no runtime input, so PlayerActionHandler writes it on the components, as Player.setSleeping did.
  • Session lookups in place of player lookups. The arm swing, the emote and the shared-container fan-out walk World.getViewers(), the skin relay SessionManager.getSessions(), and the permission target is SessionManager.sessionOf. A sender's op status is PermissionManager.isOp(session.identity.uuid). blockTargetRejection takes the session and reads getViewPosition and getWorld. A sound excludes the sender by session.runtimeId, and its actor type is compared with PLAYER_IDENTIFIER.
  • What still calls session.getPlayer(), and why:
    • Methods that post a player event: setSprinting and setFlying (PlayerActionHandler, RequestAbilityHandler), setGamemode (SetPlayerGameTypeHandler, SetDefaultGameTypeHandler), and the Player that PlayerMoveEvent and PlayerSpawnEvent carry.
    • What the player still keeps, which H-B3 left there: respawn, getRespawnPoint, a move's setPlayerPosition and setOnGround, getBody, getActor, and the item-use clock (beginItemUse, takeItemUseTicks).
    • Callees in other packages that take a Player, or a port the store player does not satisfy: the content interactions (InteractingPlayer), World.breakBlock, World.hitBlock's striker, World.addPlayer, createStartGameData, playerSpawnPositionPacket, CommandManager.dispatchCommand, the chat sender, PermissionManager.can, advancementItemCrafted, containerAnchorOf and openContainerSlots, and the enchanting and anvil code workstation.ts calls, which now takes a WorkstationPlayer port.
    • LoginHandler still builds the Player, which is H-D's.
  • Doubles. storePlayerPort(store, port) (network/handler/storePlayerPort.test.helper.ts) builds a Player double whose getStorePlayer answers a store player double, and sessionSeeingBlocks (blockTargetSession.test.helper.ts) adds getViewPosition and getWorld to sessionNamedNearBlocks' session. Tests of the input handlers give the world a dataEntities whose runtime double records the inputs, over a real PlayerComponents. The four handler tests H-A3 left on Player (ItemStackRequestHandler, its replay, workstation and BossEventHandler) build ports or a session instead; BossEventHandler.test.ts still takes its session from fakePlayer().
  • For H-D: testing/playerPort.test.helper.ts' prose names the deleted KickedPlayer and PlacedPlayer, and no wave H-C package owns that file. PlayerActionHandler maps between PlayerMining and BlockMining itself where Player.getMining and setMining did, because world/BlockMining.ts was H-C1's; once Player goes, a null-tolerant pair beside blockMiningOf is the one home.
  • Checks at H-C3's head, on program head 48e6a23ee1: the Prismarine suite with JSP_CONTENT_VANILLA=1 (429 files and 4355 tests passed, 2 files skipped), SessionReplay.test.ts with no baseline touched, client e2e movement, jump, eating, mining-drops, block-placement, villager-trade, recipe-book, teleport and death-respawn in three batches, pnpm run typecheck, pnpm run lint and pnpm run lint:custom pass. No adjacent bug was confirmed.

H-C2, the sessions, services and the plugin API, left these for H-D. Item 5's teardown and question 2 are done. Of item 29, only the boss bar's legacy form is gone; every other one still has a caller outside H-C2's files. Item 4 is prepared, and item 42 is done for H-C2's files. No behavior changed but the plugin API: question 2's events, and the boss bar.

  • The player events (question 2). PlayerEvent.getPlayer() is gone, and getSession() returns the player's PlayerSession beside getRuntimeId() and getIdentity(), which now read the session. The constructors take a PlayerEventSubject: the session, or anything whose getNetworkSession() returns one. The session is found the first time a listener asks, as the ref was, because the handler tests (H-C3's) pass player doubles with no session. PlayerMoveEvent starts from the position of the player it is given, or of the session's player when it is given the session. Player.ts (setGamemode, setSprinting, setFlying) and DimensionTravel still pass the player. MovePlayerHandler and ResourcePackClientResponseHandler pass session.getPlayer() and can pass session as it is, which changes nothing a listener sees. H-D moves the three Player methods' events with their bodies, passes the session everywhere and drops the second form. The Changeset is player-events-carry-the-session.md (minor), and docs/guide/extending.md's example reads event.getIdentity().name. playerConnect, playerDisconnect and playerDespawn are declared but never posted (#2972).
  • The teardown (item 5). ClientConnection.closePlayerSession calls PlayerSession.leave(), which runs getPlayer().disable() and then disable() whether or not the first part threw, as the connection did. H-D replaces the first part with the session's own leave. H-S0's edge case holds: when Player.disable throws before removePlayer, the player stays in its world's map with a forgotten session.
  • Item 29. BossBar.addPlayer, removePlayer and answerQuery take a PlayerSession only, since BossEventHandler passes the session after H-C3; the Changeset is boss-bar-takes-the-session.md (minor). Each other legacy form names its remaining callers in its comment. PlayerKeySource and KeyedPlayerSource: Player.enable (loadAdvancements(this), enchantmentSeedOf(this)), raiseAdvancement and the enchanting table, which take the ports advancement/triggers.ts declares (H-C1), workstation.ts (H-C3), and triggers.test.ts and PlayerPersistenceFormat.test.ts. BanSubject: LoginHandler, which passes { getName } with the display name. PermissionHolder: Player.enable. Narrowing any of them breaks a file no H-C package of this wave owns, so H-D drops them once those callers pass the key, the session or the identity.
  • Item 4, prepared. SessionPlayer in sessionPlayers.ts is what getPlayer() returns and what the session directory, SessionManager's player lookups and sessionFor hold. It is Player today, and H-D points it at StorePlayer. The session tests' doubles for a session's player stub SessionPlayer, so they follow the switch, and break only where they reach members a store player lacks. sessionPlayerRefOf lost the events as a caller; createConnectedPlayer is the last. getAllPlayers keeps its callers in Server.ts, modules/network.ts, the handlers and the benchmarks; the ones in H-C2's files read getSessions().
  • The session as the way in. PermissionManager.setOp finds the session with findSession and sends through it. The remote console's status, the AvailableCommands soft enum, kickAllPlayers and /ban read each session's identity and kick through the session. The command host's verbs that only send take sessionsOf(server, refs); the ones that change the player and then tell the client take resolveTarget, which hands back the session and the player. CommandPlayer dropped getNetworkSession, kick and sendMessage. /msg, /say and the rest send through PlayerSession.sendMessage with the defaults Player.sendMessage gave them.
  • Ports in the session. sendMoveActor and queueMoveActor take a MovedActor instead of an Entity, and OpenTrade.trader is a TradingActor instead of a WorldActor. PlayerSession.ts imports neither class.
  • What H-C2's files still name, and why:
    • Player: sessionPlayers.ts (SessionPlayer, LegacyPlayers, sessionPlayerRefOf), which is H-D's switch.
    • Entity: CommandManager.dispatchCommand's target. Its guard isCommandExecutor is in CommandExecutor.ts, which is H-C1's.
    • WorldActor: command/host/entities.ts, command/host/support.ts and item/actorPort.ts. They hold what World.getActor returns and follow H-C1's narrowing of the alias. entities.ts's /tp keeps the entity.setPosition branch for a legacy entity after the StoreActor one. Once the alias is StoreActor, the narrowing leaves never there and the branch no longer compiles, so H-C1's narrowing has to come with deleting it. host.test.ts builds its mobs and a dropped item with stubOf(Entity, ...) for the same code.
    • getNetworkSession() through a port: enchantment/enchantingTable.ts and item/interaction.ts, whose ports the content interactions and workstation.ts hand a Player. commandStartup.test.ts, CommandManager.test.ts, host.test.ts and support.test.ts use playerOnPrototype for players the command host reads, and PermissionManager.test.ts stubs Player as a permission subject. The rest of part 4's H-C2 tests no longer import a legacy class.
  • Checks at H-C2's head, rebased on program head 35e5656efc: the Prismarine suite with JSP_CONTENT_VANILLA=1 (432 files, 4365 tests), the commands package (11 files), the whole parity suite (53 files, protocol.dimension-change among them), client e2e spawn, rejoin and duplicate-login, then chat, dimensions and title, pnpm run typecheck, pnpm run lint:custom and pnpm run lint pass. rejoin passed on a second run: the first time the client exited before its control endpoint came up, with "macOS did not apply the hidden rendering activation policy", before any join. The adjacent gap found was already #2972.

H-C1, the world and entity side, left these for H-D. Item 36 is done but for one call, item 34 is done for H-C1's callers of getActors, item 33 is done for the entity index and the blast, and item 42 is done for H-C1's files. No behavior changed and no digest moved.

  • Item 33, the index. EntityIndex lost addLegacy, refileLegacy, removeLegacy, legacyWithin and the body attachment, and World no longer files a legacy entity in it. A blast in entity/Explosion.ts takes store actors from storeWithin alone, and reads its eye height fraction from ENTITY_EYE_HEIGHT_FRACTION. Explosion.test.ts catches store entities through actor doubles over a real store, and WorldEntityIndex.test.ts lost the cases that only exercised the legacy half.
  • Item 33, what is left. World's legacy half stays whole: the entity map, addEntity, removeEntity, getEntity, getEntities, entityRelocated and the chunk membership that serves them. Their callers are outside H-C1's files: DimensionTravel.transfer's legacy branch, /kill in command/host/entities.ts, Entity.ts (getNearestEntity, relocate), legacyAdapter.ts, and the tests SessionReplay.test.ts, DimensionTravel.test.ts, PlayerPersistence.test.ts, WorldChangeTick.test.ts, live.test.ts, serverSource.test.ts, WorldAutoSave.test.ts and benchmarks/entityOnlyChunkSave.ts. The player map stays typed by Player, because item/interaction.test.ts and benchmarks/worldUpdateChunkWork.bench.ts fill it with players, and getPlayers, addPlayer and removePlayer read it.
  • Item 33, the alias. WorldActor cannot narrow to StoreActor yet. Narrowing it breaks more than the /tp branch H-C2 named: command/host/entities.ts at familiesOf, /kill's two legacy branches and /damage as well, item/actorPort.ts's teleport, DimensionTravel.transfer, and Entity.ts, which hands this to EntityDamageEvent, EntityDeathEvent, advancementDamage, advancementKill and the portal travel. H-D narrows it with Entity.ts gone and those branches deleted.
  • Item 34. World.actors() walks the legacy map and the store's actors in place, in no order. The chunk save context, the provider's EntitySource (now any iterable), the level's entity snapshot, the stored actor restore and the end crystal's overlap check walk it, and createChunkSaveContext sorts each chunk's lists by runtime id and gives a duplicate actor id to the lower runtime id, which is what the sorted input gave. The content adapter's at, near and countOfType ask the index for store entities and dropped stacks within a horizontal radius, merge the two answers by id, and apply the old exact test (question 10). getActors keeps its sorted copy for the replay digest and for command/host/entities.ts and jsprismarineCommands.ts, outside H-C1's files. shownActors still concatenates, because WorldChangeSink.reconcile and the session's reconcile take an array.
  • After H-C4's note: LiveEntityRuntime.savedRecords and the dragon fight's eight store reads walk EntityRuntime.entities(). The one caller of all() left in source is entity/contentAdapter.ts, with the tests, so noLegacyEntity.test.ts' it.todo for all() waits on those.
  • Item 36. World reads its store and runtime without asking: update, the change tick, runInput, chunk entry and exit, the stored actor restore, the chunk change mark, shownActors and getActors. getActor keeps getEntityRuntime?., because item/interaction.test.ts builds a world whose server has no runtime. hasEntityStore stays for legacyAdapter.ts. inertEntityRuntime and three world doubles gained a real, empty EntityStore.
  • Players through the viewers. World's spawner proximity check, raid host and both saves walk getViewers; the spawner measures from PlayerSession.getViewPosition less the eye height. The content adapter's players and the dragon fight's boss bar and killer read getViewers. breakBlock takes a BlockBreaker and the chunk coverage a CoveringPlayer port. Two doubles outside H-C1's files followed: worldDestruction.test.ts' world answers a viewer, and fakePlayer's world, which already listed its player as a viewer, answers getKnownSpawnPosition, which the spawn cycle asks once the adapter sees that player.
  • Narrowed to ports. ParseTargetSelector is generic over a SelectorEntity and finds @p itself with getNearestEntity's rule; ParseTildeCaretNotation reads only the source's position; isCommandExecutor takes a CommandTarget. CommandManager.dispatchCommand still annotates its target CommandExecutor | Entity, which can now read CommandTarget. loose and LiveEntityRuntime.killEntity take a StoreActor. After H-C3's note, playerMiningOf and blockMiningOf take and answer null through overloads, and PlayerActionHandler calls them directly; Player.ts keeps its own null checks.
  • What H-C1's files still name, and why:
    • Player: World.ts's player map, getPlayers, addPlayer and removePlayer, and the prose of the legacy methods.
    • Entity: World.ts's legacy half above.
    • WorldActor: the alias itself, World.ts (actors, getActors, getActor, the digest), ChunkSaveContext.ts, BaseProvider.ts, EntitySerializer.toNBT, the three events/entity classes and advancement/triggers.ts, which Entity.ts hands this; Combat.ts's targets, which InventoryTransactionHandler takes from World.getActor; and live.ts's damageEntity, the naming, dyeing, projectile and item interaction methods, which item/actorPort.ts calls with a WorldActor, and savedRecords, which WorldReplayState.test.ts keys with legacy bodies. Level.ts imports only byRuntimeId from WorldActor.ts.
    • Tests: Combat.test.ts still swings GenericEntity doubles, because its attacker and target stay WorldActor; WorldEntityIndex.test.ts keeps two cases for the legacy chunk membership; WorldGeneratedEntities.test.ts, WorldEntityAddition.test.ts, WorldTickPhases.test.ts, WorldReplayState.test.ts and triggers.test.ts still build or stub a legacy entity for the code above. They go with the legacy half.
  • The Changeset is world-entities-from-the-store.md (minor): the index's legacy methods are gone and two parameters narrowed.
  • Found: neither blast path destroys a dropped stack, because both read the index's mob cells and a stack is filed only in its item cells (#2981).
  • Checks at H-C1's head, on program head 6c24c5a6f1: pnpm run typecheck, the Prismarine suite with JSP_CONTENT_VANILLA=1 (432 files, 4370 tests), SessionReplay.test.ts with no baseline touched, the whole parity suite (53 files), client e2e attack-entity and summon-entity, then ender-dragon and natural-spawn-placement, pnpm run lint:custom and pnpm run lint pass.

H-D, the deletion, closed C4h and slice 5. Items 4, 31, 40, 41 and 43 are done, and so are what wave H-C left of items 29, 33, 34, 36 and 42. No behavior changed but the plugin API, and no digest moved.

  • The switch (item 4). LoginHandler builds a StorePlayer with an id from entityIds and the login's identity, skin and device, standing EYE_HEIGHT below the spawn position, and createConnectedPlayer files it in Server.getStorePlayers(), which is EntityRuntimes.players and the one session directory. session.getPlayer() returns it. LegacyPlayers, sessionPlayerRefOf, SessionManager.getLegacyPlayers and the handler helpers storePlayerOf and playerInputsOf are gone; a handler reads session.getPlayer() and session.inputs(). SessionPlayer is StorePlayer.
  • What Player held moved to the session: the world, the detached view, the permissions, the joined flag (isConnected()), the chat recipient and the timer. join() and leave() are what Player.enable and Player.disable did, with the join spawn writes H-B3 named. The session owns setGamemode, setFlying and setSprinting with their events, respawn, die, getRespawnPoint, setPlayerPosition, setOnGround, setWorld, updatePlayer, stopMining, getActor, shown() and keyed(), and one-line state reads so it satisfies the mixed ports the content interactions, advancements, workstations and commands take. The store player took the state the class only forwarded: the item-use clock, the sprint, sneak and sleep flags, the fall distance, the experience, the equipment, the tags and status effects, and setAttributeValue. StorePlayer is the record's one PlayerRecordTarget (item 31).
  • The world (items 33 and 36). World's player map holds sessions, getViewers reads it, and getPlayers is gone; addPlayer and removePlayer take the session. The entity map, addEntity, removeEntity, getEntity, getEntities, entityRelocated, hasEntityStore and the chunk membership are gone. actors(), getActors() and getActor() answer StoreActors, and the WorldActor alias is gone: byRuntimeId lives in world/runtimeIdOrder.ts. getActor keeps getEntityRuntime?. for the world doubles whose server has no runtime. The legacy branches after a store one are deleted: /kill, /damage, /tp and familiesOf in the command host, the item actor port's teleport and DimensionTravel.transfer, which always moves a store entity through transferStoreEntity. CommandManager.dispatchCommand's target is a CommandTarget.
  • The copies (item 34). EntityRuntime.all() is gone. The content adapter's at walks entities() in place, EntityScene declares entities(), and the attribute flush walks World.actors(). getActors keeps its sorted copy for callers outside the tick: the replay digest and the commands.
  • The keyed services (item 29). PlayerKeySource is a key or a KeyedPlayer; KeyedPlayerSource and keyedPlayerOf are gone, and a session answers keyed(). BanSubject is { name } and PermissionHolder is { uuid }. The player events take the session only.
  • The deletions (items 40 and 41). Player.ts, entity/Entity.ts, legacyAdapter.ts, network/handler/playerPorts.ts, the root Player export and entity/index.ts's export * from './Entity', which became a named DamageCause export and lowered the barrel baseline from 5 to 4. The JSDoc baseline lost the deleted classes' entries. The Changeset is player-is-the-store-player.md (minor) and lists each plugin-facing change.
  • The proxy test (item 43). noLegacyEntity.test.ts' two it.todo are real: no Entity or Player class is declared and neither file exists, the runtime has no all() and no source calls getEntities(), and no source copies the store through all(). Its eight cases pass.
  • Tests. fakePlayer returns the session beside its store player, sessionOnPrototype replaces playerOnPrototype, connectedSession builds a login's pair, and Player.test.ts became network/PlayerSessionPlayer.test.ts. testing/genericEntity.test.helper.ts, storePlayerPort.test.helper.ts, Entity.test.ts, WorldEntityAddition.test.ts and Damage.pipeline.test.ts are gone with the code they covered. Damage.pipeline.test.ts drove Entity.damage. The store's versions of its rules are covered elsewhere: the grace period, the knockback and the void in damagePipeline.test.ts, which gained a block for the rules a swing keeps; absorption in playerSystems.test.ts; knockback resistance in EntityRuntime.test.ts; and the hurt animation and the plugin events in live.test.ts. Combat.test.ts swings at store cows. It lost its grace-period case, because the fixture cow is not of the mob family, which the grace needs; damagePipeline.test.ts covers the grace. PlayerPersistence.test.ts' cross-level digest case lost its mob, because a store mob's persistentIdentity is a fresh UUID each spawn; WorldReplayState.test.ts covers mob hashing.
  • What still names a deleted class, and why: Java references such as Entity.checkInsideBlocks, which name Mojang's classes; the comments in storePlayers.ts, playerSystems.ts, live.ts, itemActor.ts and EntityRuntime.ts that compare a store rule with what a legacy player or body did, which is the parity they keep; legacyDamage.ts, whose DamageSource the plugin events and the advancement triggers still take; EntityStore.writtenLegacy, which logs a rider's vehicle link when the rider is in no store; and the test names and prose of noMirror.test.ts and noLegacyEntity.test.ts.
  • Flaky, not caused by H-D: in the full client e2e run, the batch of teleport, teleport-while-dead and title failed once at the suite reset ("the client never stood at 0.5, 64, 0.5; at 0.5, -60, 0.5"), with the server log repeating "Teleported 1 entities" and "Dropped a position ... sent before its teleport". The batch then passed three times, each scenario passed alone, and the batch passed three times on the program head. In the root pnpm run test, run at a load average near 12, modules/ticker.test.ts' "uses a timeout for a tick that finishes before its fixed deadline" and HttpSignaling.loopback.test.ts' WebRTC join (a 20-second timeout) failed once and passed three times alone; neither file changed.
  • Checks at H-D's head, on program head 7f8b1e99f0: pnpm run build, pnpm run typecheck, the Prismarine suite with JSP_CONTENT_VANILLA=1 (430 files, 4334 tests), SessionReplay.test.ts with no baseline touched, the whole parity suite, the full client e2e suite in batches of two or three, pnpm run e2e:client:dist, pnpm run lint:custom and pnpm run lint pass. pnpm run test passed but for the two flaky cases above. No adjacent bug was confirmed.

Phase C: the store ​

PackageSliceDepends onWork
C12A4, A5, B1The entity store, logged setters, the one index and the adapter over legacy entities. Component state the entities package keeps in WeakMaps moves onto the instance, where the store can log and save it
C23C1Items and the eye of ender move into the store
C34C1Staged damage and death, the mirror deleted, damage synchronous
C45C2, C3, B3, B5Players move into the store. The legacy entity classes and the adapter are deleted. Split into the packages below

C2 and C3 run side by side if C1 leaves item code and damage code in separate files. Otherwise C3 follows C2.

The player spike found that a player fits the store with three host rules, which ADR 0008 now records, and that C4 is too large for one package. 79 source files and 117 test files name Player. C4 splits as follows.

PackageDepends onWork
C4bC1, C3The player overlay, the rule that the client owns a player's movement, and a death that keeps the entity
C4cC1Player components with logged setters, and an inventory that logs one entry for each slot
C4dC4b, C4cPlayer systems: hunger, breath, fall damage, consumption and mining, reading loaded chunks only
C4eC4c, B3Persistence from the store entity, with the saved record unchanged
C4fC4cThe session holds the entity id. Convert the files that name Player, split by directory
C4gC4b, C4cShow store players to clients through the current replicator until slice 7
C4hC4b to C4gConvert the tests, then delete the legacy entity classes and the adapter

C4b and C4c run side by side. C4d, C4e, C4f and C4g run side by side after C4c. The spike's note lists what the adapter of C1 must provide while players are still legacy, and that list is part of C1's brief. One item needs an answer when C3 lands: the mirror is what shows store mobs to legacy sessions today, so from the slice that deletes it until slice 5 something else has to encode them.

The change log module of A4 left questions for C1, answered here.

  • A chunk entering the world is a unit of its own. The input step admits a chunk as one input, and no other write shares its span, so a span that would have to be undone never holds a chunk entry.
  • A membership entry carries one record, the entity's saved form, and that form has to contain everything replication sends on first sight. ADR 0012 and ADR 0016 describe the same record.
  • Inverses are written through setters that do not log. If writing an inverse throws, the store can no longer be trusted, so the world stops ticking and reports the failure. It does not go on with a half-undone unit.
  • A chest's slots are fields of its block entity, addressed by block position. An entity's inventory slots are addressed by entity id.

Phase D: handlers and replication ​

PackageSliceDepends onWork
D16C4The shared handler shape in @jsprismarine/protocol and the narrow server context
D26D1Convert the input-lane handlers to the shape, split into groups that run side by side. The client adopts the shape
D37C4Block and block entity writes go through logged setters
D47D2, D3The replica, one audience per change, one encode per codec group, and sends out of the simulation

D3 runs beside D1 and D2.

D1, the handler shape and the narrow context, left these for D2. Counts are from program head d2d36c1c2f.

  • The shared shape is @jsprismarine/protocol/handler/PacketHandler. PacketHandler<P, C, Phase> has phases and handle(packet, context), which may return a promise. LaneHandler<P, C, L, Phase> adds the lane. InputHandler<P, C, Phase> is the input-lane form, and its handle returns undefined, not void: a function that returns a promise is assignable to one that returns void, so only undefined makes an asynchronous input-lane handler fail to typecheck. ServerboundHandler<P, Contexts, Phase> is the union, with each lane's context taken from Contexts. The packet, the context and the phases are the side's own, so the client's decoded payloads fit as they are. The lanes (LANES, Lane, strictestLane) moved to @jsprismarine/protocol/handler/Lane, and Prismarine's network/handler/Lane.ts is gone. The protocol root exports neither module (ADR 0010).
  • The server's context is network/handler/HandlerContext.ts. A connection-lane or read-lane handler gets a SessionContext: the session, a log (the logger's levels) and a config picked to the six settings handlers read. An input-lane handler gets an InputContext, which adds services and step. HandlerServices is every server-wide service a handler calls today, each a Pick of the members it calls: the content, the content world's actorsAt, the creative inventory, the sessions, the permissions, the commands, the chat, the console sender, the plugin events and the default game mode. It covers all 72 handlers, so a D2 package does not need to add one. InputStep has the tick, output(send), which takes a send the handler started without awaiting it, and defer(work), which takes work that still has to wait.
  • The server's handler shapes are network/handler/SessionHandler.ts: ConnectionLaneHandler, ReadLaneHandler and InputLaneHandler, each with takes: 'context', which is how takesContext tells them from the handlers that take the Server. The field goes when the last handler that takes the server goes.
  • The adapter is network/handlerContexts.ts, the one module that turns a Server into a context, outside network/handler/. HandlerContexts.of(server).run(handler, packet, session) awaits a connection-lane or read-lane handler. It applies an input-lane one inside World.runInputNow, a synchronous change log unit (EntityStore.unit) reached through applyInputNow in world/inputUnit.ts. Then it runs the deferred work in order, each piece through applyInput as a unit of its own, and last it awaits the outputs, so the next input still starts after all of it. The context reads the server's parts when a handler uses them, so the content is not read before the pack stack is built. NetworkDispatcher.runHandler sends a handler with takes there and every other handler down the path it took before. appendHandler merges either shape with the other into a handler that takes the server.
  • What changed for a converted input handler: its writes close as one unit before its sends are awaited, so a failed send no longer undoes them, and a deferred piece that throws undoes only its own writes. The order of effects is unchanged.
  • Converted, one of each kind: NetworkStackLatencyHandler (connection), SubChunkRequestHandler (read), AnimateHandler (an input that only relays, through step.output) and MobEquipmentHandler (an input that writes the hand slot). Their tests build contexts with sessionContext and inputContext in testing/handlerContext.test.helper.ts; inputContext keeps the outputs and the deferred work and settled() runs them as the step does. Each converted handler destructures its context ({ session, log, step }: InputContext), which keeps the diff of a conversion to the signature and the log calls.
  • Lane.test.ts' check that connection-lane handlers keep out of the world now reads a context parameter: the session destructured from a SessionContext or InputContext, or read as context.session, is checked as a PlayerSession.
  • The proxy test is network/handler/noServerAccess.test.ts, an explicit allowlist that D2 empties, not a script baseline in the style of check-barrels.mts. Each module either reaches the server or does not, so there is no count to lower; the test lives beside the handlers like noMirror.test.ts and noLegacyEntity.test.ts and runs in every Prismarine test run; and the list is grouped by D2 package, so packages that run side by side delete lines in their own block. A module reaches Server when it imports anything from src/Server, imports a binding named Server from the package root, or calls getServer() on something it holds, which PlayerSession.getServer() and World.getServer() still allow. The test fails when an unlisted module reaches the server and when a listed one has stopped. 9 of the 80 modules under network/handler/ reach no server. The target gates once the list is empty.
  • Not narrowed: the context's session is the whole PlayerSession, which still answers getServer() and getWorld(). The test catches the call in a handler module, not in the code a handler calls.
  • Found: in the client suite, four NetherNet tests fail together with connecterror:10 (#2983), and the replay test can stop on a held RakNet client timer when files run one after another (#2984). Both happen on program head d2d36c1c2f too, and each file passes alone.
  • Checks at D1's head, on program head d2d36c1c2f: pnpm run build, pnpm run typecheck, the Prismarine suite with JSP_CONTENT_VANILLA=1 (432 files, 4349 tests), the protocol suite (395 files), the parity suite (53 files), client e2e spawn, chat, movement, duplicate-login and subchunk-request-delivery, pnpm run lint:custom and pnpm run lint pass. SessionReplay.test.ts passed with no baseline touched, in two parallel client runs and three times alone. The client suite's other failures are #2983 and #2984.

D2: the remaining handlers on the context ​

D2 moves the other 68 handlers and workstation.ts off the Server, in seven packages that run side by side, each in its own worktree, and one serial close. A package owns the files in its row and the tests beside them, and nothing else. The lists match the blocks of noServerAccess.test.ts; a package deletes its block as it converts.

How a handler converts:

  • It implements ConnectionLaneHandler, ReadLaneHandler or InputLaneHandler, declares takes = 'context', destructures its context, and logs through log.
  • An input-lane handler's handle returns undefined and awaits nothing. A send goes to step.output. Work that still waits for something a later slice removes goes to step.defer, with a comment naming that slice: a world change or the credits (9), the respawn search (9), a command or a chat line (9, unless the command is synchronous by then), or a disk write that the ADR moves off the tick. A block read uses a loaded-chunk accessor and rejects an unloaded chunk, as ADR 0014 says, and does not defer.
  • A helper the handler passes the Server to takes the service it reads instead, such as contentUse(content, contentWorld, player) for contentUse(server, player).
  • Each package traces every one of its handlers' lanes through what the handler calls, which closes the last item of "Checks to run" for its files, and moves a handler whose trace shows another lane.
PackageFiles under network/handler/What is not mechanicalGate beyond the unit tests
D2a connection lanethe 31 connection-lane session handlers in its block, from ActorPickRequestHandler to UpdateClientOptionsHandlerNothing. SetDifficultyHandler reads config.getDifficultyLane.test.ts; client e2e spawn
D2b read laneAnvilDamage, BookEdit, BossEvent, LabTable, LecternUpdate, PlayerToggleCrafterSlotRequest, StructureBlockUpdate, StructureTemplateDataRequest, and BlockTarget.tsThe six that are write requests the server drops stay in the read lane until their write exists (B1). The parity check that rejecting an interaction with an unloaded chunk matches Java ("Checks to run"), as a test under a declared family, since BlockTarget is where the rejection livesThe new parity test; client e2e block-placement
D2c relays and session inputsActorEvent, ContainerClose, Disconnect, Emote, Interact, LevelSoundEvent, PlayerSkin, RequestAbility, ResourcePackChunkRequest, ServerboundLoadingScreen, SetPlayerGameType, ShowCreditsThe relays move to step.output. ShowCredits and ServerboundLoadingScreen complete a held world change, which they defer. Disconnect closes the connection, which the dispatcher already holds as an inputClient e2e movement-fanout, dimensions, ender-dragon, title; parity protocol.dimension-change
D2d movement, actions and inventoryInventoryTransaction, ItemStackRequest, MovePlayer, PlayerAction, PlayerAuthInput, RequestChunkRadius, Respawn, and workstation.ts, tradeScreen.ts, eyeOfEnderEcho.tsOne package because PlayerAuthInputHandler runs the other three's code and applyClientMovement. PlayerAction's block read is the IN_TICK_CHUNK_LOADS entry for slice 6, which goes with it. The respawn search is deferred. workstation.ts stops calling getServer()The replay suite with unchanged digests; client e2e movement, jump, teleport, mining-drops, block-placement, eating, villager-trade, recipe-book, death-respawn
D2e commands, chat and permissionsCommandRequest, RequestPermissions, SetDefaultGameType, SettingsCommand, TextADR 0014: an operator list change updates memory synchronously and persists off the tick, which needs a synchronous PermissionManager member. The default game mode's config write the same. Commands and chat are deferredClient e2e chat; the commands package's suite
D2f the joinLogin, ClientToServerHandshake, RequestNetworkSettings, ResourcePackClientResponse, SetLocalPlayerAsInitialized, ConnectionPacketHandler.tsThe three handlers before a session exist take a ClientConnection, so the package designs their context in a file of its own. B1's note moves building the player and reading the spawn in front of the join, whose result enters the world as an input. ConnectionPacketHandler.ts goesThe replay suite; client e2e spawn, rejoin, duplicate-login, dimensions
D2g the clientpackages/client: ClientPacketRegistry.ts, ClientSession.ts, Client.ts, DimensionChange.ts, Credits.ts, login/LoginSequence.ts and their testsThe client registers PacketHandlers by packet name and phase over its decoded payloads with a client context, in place of each responder's on('packet') listener and isPacket chain. The client has no world tick, so its handlers stay asynchronousThe client suite and SessionReplay.test.ts with unchanged digests
D2 close, after the othersPacketHandler.ts, SessionHandler.ts, network/handlerContexts.ts, NetworkDispatcher.ts, PacketHandlerRegistry.ts, noServerAccess.test.tsDelete the shape that takes the server, the takes field and takesContext, and the dispatcher's and appendHandler's second path. The allowlist is empty, and the test asserts that nothing reaches the server. Count the step.defer calls through reportProgress, as the work slice 9 has leftEverything in the gate for each slice; the full client e2e suite

Rules that keep the packages apart:

  • Only D2e edits HandlerContext.ts, for the members it owns (permissions, defaults, commands, chat). Another package that needs a service changed waits for the close.
  • Only D2f edits NetworkDispatcher.ts and the KNOWN_WORLD_ACCESS list in Lane.test.ts. No package edits handlerContexts.ts or testing/handlerContext.test.helper.ts; a package that needs another helper adds a file of its own under testing/.
  • Narrow a helper's parameter freely. Narrow a return type or an exported member only when every reader is in the package.
  • Each package adds its own Changeset. A handler's handle signature is public through Prismarine's "./*" export, so a package that converts one adds a minor one; D2g's changes the client's public API, which is minor too.
D2a: the connection lane ​
  • The 31 connection-lane session handlers in D2a's block, from ActorPickRequestHandler to UpdateClientOptionsHandler, take a SessionContext, and the block is gone from noServerAccess.test.ts. 40 of the 80 modules under network/handler/ reach no server.
  • Each handler's lane was traced through what it calls, and none moves. 25 log the packet and drop it, reading only the session's identity or runtimeId; PacketViolationWarningHandler also names the rejected packet from latestCodec, which is fixed at startup. ClientCameraAimAssistHandler, SetPlayerInventoryOptionsHandler and TickingAreasLoadStatusHandler do nothing. ClientCacheStatusHandler and ClientCacheBlobStatusHandler call setClientCacheEnabled and handleClientCacheBlobStatus, which touch only the session's blob cache and send on its own connection. SetDifficultyHandler reads config.getDifficulty and answers on its own connection.
  • No step.defer, since a connection-lane handler has no input step. No service was missing from HandlerContext.ts, and the package edited no shared file.
  • For the close: Lane.test.ts still lets a connection-lane handler call server.getLogger and server.getConfig (SERVER_MEMBERS) and import ../../Server (CONNECTION_IMPORTS). No session handler in the connection lane uses either now, but the join's handlers and ResourcePackChunkRequestHandler still take the server. Both allowances go with the shape that takes the server.
  • Found: Server.test.ts' "refreshes the process title every five seconds" waits on the real clock and failed once in the full Prismarine run on a loaded host, then passed alone. It is tagged performance, which CI leaves out, so no issue was opened.
  • Checks at D2a's head, on program head e671fe72cb: pnpm run typecheck, pnpm run lint:custom, pnpm run lint, the protocol suite (395 files), Lane.test.ts, SessionReplay.test.ts with no baseline touched, and client e2e spawn pass. The Prismarine suite with JSP_CONTENT_VANILLA=1 passed 432 of 433 files (4376 tests), the one failure being the performance test above. In the spawn run the server logged ClientCacheStatusHandler and ClientCacheBlobStatusHandler handling the join's packets through the context.
D2b: the read lane ​
  • Converted, all eight in the read lane: AnvilDamage, BookEdit, BossEvent, LabTable, LecternUpdate, PlayerToggleCrafterSlotRequest, StructureBlockUpdate and StructureTemplateDataRequest. Each implements ReadLaneHandler, declares takes = 'context' and destructures { session, log }. D2b's block of noServerAccess.test.ts is gone. No handler is in the input lane, so there is no step.defer.
  • The lane trace agrees with the declarations. Six read the sender's view position and one loaded chunk through blockTargetRejection and log; BookEdit reads the sender's inventory and logs; BossEvent replays a bar to the sender alone through BossBar.answerQuery. None writes and none answers anyone but its sender. The six write requests the server drops (AnvilDamage, BookEdit, LabTable, LecternUpdate, PlayerToggleCrafterSlotRequest, StructureBlockUpdate) move to the input lane when their writes exist.
  • BlockTarget.ts did not change. Its BlockTargetSender was already Pick<PlayerSession, 'getViewPosition' | 'getWorld'>, and it reaches no server.
  • sessionSeeingBlocks in network/handler/blockTargetSession.test.helper.ts now also returns a SessionContext over its session, whose log fills logged, and takes player members, so the BookEdit test uses it too. The shared testing/ helpers were not edited.
  • The "Checks to run" item on rejecting an interaction with an unloaded chunk is confirmed by the new parity family gameplay.block-targets. packages/parity/src/javaClassFile.ts parses a class file from the pinned Java jar into each method's calls in bytecode order. Java 26.3's ServerGamePacketListenerImpl.handlePickItemFromBlock checks reach (isWithinBlockInteractionRange), then ServerLevel.isLoaded, then reads the block, and returns at the first check that fails; updateSignText checks hasChunkAt before getBlockEntity. blockTargetRejection makes the same checks in the same order. Against a real World with one chunk in memory, the six handlers drop a position in reach in an unloaded chunk before reading its block, and the provider is asked for no chunk. The reach bound itself differs and is not compared: Java measures from the eyes to the block's box with the interaction range plus one, and the server from the view position to the block's corner with the creative reach plus one. handleUseItemOn checks reach and mayInteract but not whether the chunk is loaded, so it is not part of the row.
  • Left for the D2 close: nothing. These handlers need no HandlerContext member, and the close can tick the "Checks to run" item.
  • Checks on program head 0b2c42f684: pnpm run typecheck, pnpm run lint:custom and pnpm run lint, the parity suite (54 files, 312 tests), SessionReplay.test.ts and client e2e block-placement pass. The Prismarine suite with JSP_CONTENT_VANILLA=1 passes except files that fail differently on each full run under load (Server.test.ts, live.test.ts, blockStateAllocation.test.ts); each passes when run alone. No replay digest changed.
D2c: relays and session inputs ​
  • Converted, with the lane each trace showed: ActorEvent, Emote and PlayerSkin (input, relays through step.output); ContainerClose, Interact, RequestAbility, SetPlayerGameType, ShowCredits, ServerboundLoadingScreen and ResourcePackChunkRequest (input); Disconnect (connection, moved from input).
  • Lane traces. Each input handler writes or shows something: the skin and the player list entry, the inventory, the crafting grid and dropped stacks, the window table, the flight state, the game mode, a world change, or a teardown. ResourcePackChunkRequest stays an input because its kick goes through ClientConnection.disconnect, which takes the player off directly and not through the held removal. Disconnect only logs and closes the transport, and the dispatcher holds the removal that the closed transport causes as an input of its own, behind the client's earlier inputs. That makes it a connection-lane handler, and it closes the Disconnect half of the last item under "Checks to run". The goodbye no longer waits for an input step before the transport closes.
  • step.defer calls, four in all:
    1. SetPlayerGameType: PlayerSession.setGamemode, which awaits the send that stops block cracking before it writes the game mode. Slice 7.
    2. ShowCredits: completeCredits, a world change. Slice 9.
    3. ServerboundLoadingScreen: completeDimensionChange, which waits for the arrival's spawn and may start a queued world change. Slice 9.
    4. ResourcePackChunkRequest: the kick, whose teardown saves the player record and announces the departure in chat. Slice 9.
  • Four writes stay in the handler's unit only because the method that makes them writes before its first await: PlayerSession.setFlying (RequestAbility), openMainInventory's window table entry (Interact), releaseArrivalChunks' released flag (ServerboundLoadingScreen) and World.dropContents (ContainerClose). The handler tests stub these methods, so nothing fails if one of them gains an await before its write; the write would then leave the unit. Slice 7, which takes sends out of these methods, removes the risk.
  • ContainerClose now empties the grid and drops the overflow before its reply settles rather than after. The writes are the same unit and the sends go out in the same order.
  • Left for the close, then done in D2e: LevelSoundEventHandler stayed on the shape that takes the server. It relays the client's own packet through NetworkWorldReplicator.sendAround for the sender's world, and HandlerServices has no replicator. It needs a member such as replicators: Pick<WorldReplicators, 'for'>, or one narrowed to sendAround. Rebuilding the audience in the handler would copy getViewers, and getProximityBroadcast is not in HandlerConfig either.
  • Edited outside the handler files: one assertion in Lane.test.ts' "flags the world access it guards against", which read SetPlayerGameType's server.getPermissionManager call and now reads its session.sendGamemode call. No converted handler exercises the test's Server branch any more, so the close can delete that branch with the shape that takes the server. PlayerSessionDimensionChange.test.ts gained a reportScreen helper for its three loading screen calls; D2d edits the PlayerAuthInputHandler calls in the same file.
  • Checks at this package's head, on program head e671fe72cb: pnpm run typecheck, pnpm run lint:custom and pnpm run lint; the Prismarine suite with JSP_CONTENT_VANILLA=1 (433 files, 4378 tests), the protocol suite (395 files), SessionReplay.test.ts with no baseline touched, parity protocol.dimension-change, and client e2e movement-fanout, dimensions, ender-dragon and title pass.
D2d: movement, actions and inventory ​
  • Converted, all in the input lane, where each trace kept them: InventoryTransaction, ItemStackRequest, MovePlayer, PlayerAction, PlayerAuthInput, RequestChunkRadius and Respawn. Each writes the player, its inventory, its window, the world or the session's view distance. The D2d block of noServerAccess.test.ts is gone. workstation.ts takes the enchantment registry and the world's loaded blocks in place of getServer(). tradeScreen.ts and eyeOfEnderEcho.ts reached no server already; InventoryTransaction hands the echo step.tick.
  • PlayerAuthInput applies a movement, flag actions, block actions, an item use and a stack request from one packet, each through the handler that owns it. Once one part defers, a part applied at once after it would take effect first, so orderedParts (network/handler/inputParts.ts) defers every later part whole behind it and runs the work a deferred part defers inside it. A block placed in the same input as a break still goes down after the break.
  • PlayerAction's block read, the slice 6 entry in IN_TICK_CHUNK_LOADS, is gone. A block action, and a break transaction with client-timed breaking, is dropped at verbose level when its chunk is not loaded, and the crack timing reads the block through World.loadedBlockAt. That is a behavior change: such a packet used to load or generate the chunk.
  • step.defer calls, nine in all:
    1. PlayerAction StartDestroyBlock and ContinueDestroyBlock with server-timed breaking: World.startBlockCracking, which reads the block through getBlockState. Slice 6.
    2. The same actions with client-timed breaking: World.hitBlock and the crack event. Slice 6.
    3. PlayerAction CrackBlock: World.hitBlock. Slice 6.
    4. PlayerAction PredictDestroyBlock: World.breakBlock, which writes through setBlockByName. Slice 9.
    5. PlayerAction CreativeDestroyBlock: World.breakBlock. Slice 9.
    6. PlayerAction Respawn: PlayerSession.respawn, which asks the respawn search. Slice 9.
    7. InventoryTransaction DESTROY with client-timed breaking: World.breakBlock. Slice 9.
    8. InventoryTransaction melee: meleeAttack, which awaits its sounds before it says whether the blow landed, and the hunger depends on that. Slice 7.
    9. Respawn for a dead player: the respawn search and the change of world. Slice 9.
  • Writes that happen after a wait, in an output, so outside the handler's unit: refreshEnchantOptions sets the window's bookshelfPower after it counts the shelves, and PlayerSession.setViewDistance starts the chunk streaming after its first send. Nothing reads either before the next input. Several outputs depend on a write that comes before the method's first await, as D2c noted for its four: setPlayerPosition, setSprinting, setFlying, dropFromPlayer and loose. Slice 7 removes the risk.
  • ItemStackRequest drops its overflow stacks before the response is sent rather than after. The stacks are the same unit's writes, and nothing the response or the trade offers read changes with them.
  • Left for the close: the workstation input slots (OpenBlockContainer.inputs, written by ItemStackRequestHandler) are still not logged. Logging them as an inventory-slot container of the player needs a container name in inventory/slotLog.ts, a restore in the player systems' restoreSlot, and the setter on PlayerSession's open window, all outside this package's files.
  • Edited outside the handler files: world/inTickChunkLoads.ts (the entry above), contentPayloads.ts (joinContentPayloads takes the three log levels it writes at), the idleMovement benchmark, and the callers in entity/data/live.test.ts, inventory/blockContainer.test.ts and PlayerSessionDimensionChange.test.ts. The tests that build a Server double reach the handlers through testing/serverInputContext.test.helper.ts, a new helper that reads each part from the double.
  • Checks at this package's head, on program head 0b2c42f684: pnpm run typecheck, pnpm run lint:custom and pnpm run lint pass. SessionReplay.test.ts passes with no baseline touched. Client e2e movement, jump, teleport, mining-drops, block-placement, eating, villager-trade, recipe-book and death-respawn pass. The Prismarine suite with JSP_CONTENT_VANILLA=1 passed 431 of 434 files (4380 tests) at load average 33 to 51. Its failures were the two performance tests in Server.test.ts and timeouts in vanillaEntityRuntime.test.ts, live.test.ts and Server.test.ts, and each of those files passes when run alone. The replay test timed out once while the suite ran beside it, and passed when run alone.
D2e: commands, chat and permissions ​
  • Converted, all input lane: CommandRequest, SettingsCommand and Text (a command or a chat line), RequestPermissions (an operator list change), SetDefaultGameType and LevelSoundEvent. Each writes or shows something to other players, so none moves lane. LevelSoundEvent is D2c's leftover: it relays through WorldReplicators.for(world).sendAround, which is synchronous, so it sends nothing through step.output.
  • HandlerServices gained permissions (isOp, setOpNow, can), defaults (setGamemode), commands (dispatchCommand), chat (send) and replicators (for). handlerContexts.ts wires each as a getter or a bound method.
  • Memory first, disk after: PermissionManager.setOpNow runs PlayerToggleOperatorEvent and changes the operator list before it returns, then queues the ops.json write behind the writes queued earlier. Config.setGamemode(gamemode, true) changes the value at once and queues the file write. A failed write is logged, and the change in memory stays. PermissionManager.setOp keeps its contract on the same pieces, and now logs a failed write.
  • step.defer calls, four in all:
    1. CommandRequest: dispatchCommand and the CommandOutputPacket reply. A command can do anything. Slice 9, unless the command is synchronous by then.
    2. SettingsCommand: dispatchCommand. Slice 9, the same.
    3. Text: ChatManager.send, which runs the plugins' chat listeners. Slice 9.
    4. SetDefaultGameType: PlayerSession.setGamemode for every session, which awaits its sends and writes players in other worlds, then the default's resend. Slice 9, where a change to another world travels as a message.
  • Fixed: PermissionManager.setOp sent the target's command list before it changed the operator list, so a player made an operator got the commands of a non-operator and one who lost the status kept the operator commands until something resent the list. It sends after the change now. PermissionManager.test.ts records isOp at each sendAvailableCommands call.
  • Left for the D2 close: HandlerContext.ts and handlerContexts.ts now carry every member the converted handlers use. The noServerAccess.test.ts blocks of D2c and D2e are gone.
D2f: the join ​
  • Converted, with the lane each trace showed: RequestNetworkSettings, Login and ClientToServerHandshake (connection, unchanged), and ResourcePackClientResponse and SetLocalPlayerAsInitialized (input, unchanged). The D2f block of noServerAccess.test.ts is gone, and ConnectionPacketHandler.ts with it.
  • The three handlers before a session implement ConnectionHandler and take a ConnectionContext, both in network/handler/ConnectionContext.ts with takesConnection. The context holds the connection, a logger, the four settings they read (ConnectionConfig) and login: the sessions, the ban list and players, a JoinPreparation. network/connectionContexts.ts builds it from the server, outside network/handler/, and the dispatcher hands it to a handler whose phases all precede the session.
  • B1's note: JoinPreparation.prepare reads the default world's spawn, builds the store player standing there, attaches its session and loads its record, in the connection layer in front of the join, as ADR 0014 places login I/O. Its result is a session whose player is in no world. That player enters its world as an input, when SetLocalPlayerAsInitialized adds it.
  • Lane traces. RequestNetworkSettings sets the codec and the compression and answers on its own connection. Login verifies the token, reserves and claims the account, kicks a duplicate and prepares the player; ClientToServerHandshake resumes that admission. The login reaches a world twice, through login.players.prepare and through the kick of a replaced session, so both stay in KNOWN_WORLD_ACCESS; neither runs inside a tick. ResourcePackClientResponse opens the play phase and raises playerSpawn, and SetLocalPlayerAsInitialized adds the player to its world, so both stay inputs.
  • step.defer calls, four in all:
    1. ResourcePackClientResponse, Refused: the kick, which saves the player's record and announces the departure in chat. Slices 8 and 9.
    2. ResourcePackClientResponse, SendPacks: the same kick. Slices 8 and 9.
    3. ResourcePackClientResponse, Completed: the join burst, which waits for the join world's spawn and ends with the join line in chat. Slice 9.
    4. SetLocalPlayerAsInitialized: World.addPlayer and completeSpawn as one piece, so a failure in completeSpawn still undoes the entry. A review later found that the undo left the session in the world's player map and the store player attached to the removed entity; World.addPlayer now lets go of both through EntityStore.undoWith. World.addPlayer writes before it returns and is asynchronous only in its signature; completeSpawn waits on sends before it writes the game mode. Slice 7.
  • createStartGameData takes the three settings and the tick it reads in place of the server, and joinContentPayloads takes only the logger levels it writes at.
  • Edited outside the handler files: NetworkDispatcher.ts, which builds the connection context; PacketHandlerRegistry.ts' RegisteredHandler union; Lane.test.ts, which reads a ConnectionContext's connection as a ClientConnection and keeps the two KNOWN_WORLD_ACCESS entries with new reasons; the inbound latency benchmark's handler stub; and the parity family protocol.negotiation, which hands RequestNetworkSettingsHandler the context.
  • For the close: the connectionContexts.ts getters read the server's logger, config, session manager and ban manager when a handler uses them, as handlerContexts.ts does. KNOWN_WORLD_ACCESS keeps LoginHandler and ClientToServerHandshakeHandler until the duplicate kick is a message to the replaced session's world. World.addPlayer is asynchronous only in its signature; making it synchronous lets SetLocalPlayerAsInitialized add the player in the handler and defer only completeSpawn.
  • Checks at this package's head, on program head 0b2c42f684: pnpm run typecheck, pnpm run lint:custom and pnpm run lint; SessionReplay.test.ts with no baseline touched; parity protocol.negotiation; and client e2e spawn, rejoin, duplicate-login and dimensions pass. The Prismarine suite with JSP_CONTENT_VANILLA=1 passed 430 of 434 files (4375 tests) at load average 50 to 68, the four failures all timeouts. Alone with a longer timeout, vanillaEntityRuntime.test.ts, bedrockSections.test.ts and registryBundle.test.ts pass, and Server.test.ts fails only the performance test D2a's notes name.
D2g: the client ​
  • The client's shape is packages/client/src/net/ClientHandler.ts. ClientPacketHandler<T> is PacketHandler<T, ClientContext, ClientPhase>, with no lane, since the client has no world tick. ClientPhase is 'login' | 'play'. ClientContext has the session, picked to send, sendImmediate, enableCompression and enableEncryption, and a log with the logger's levels. PacketHandler.ts needed no change.
  • ClientPacketRegistry.registerHandler(PacketClass, handler) holds a handler under the class's name and returns a function that removes it. The handler runs for a packet of that name only when that class decoded it, so its payload type comes from the class: an instanceof check on the packet narrows the payload, with no cast and no reliance on an id table. A DecodedPacket now carries the packet that decoded it. dispatch runs the handlers that accept the phase in registration order, over a copy of the list as EventEmitter does, and one that throws does not stop the rest.
  • ClientSession holds the phase, starting in login, and registerHandler delegates to the registry. It dispatches each packet after its packet event and before packet:<id>, counts what a handler returns in isIdle and settled until it settles, and logs a failed handler as a warning that names the packet.
  • Converted, each in its own commit: Credits handles ShowCreditsPacket. DimensionChange handles ChangeDimensionPacket and LevelChunkPacket. LoginSequence handles NetworkSettingsPacket, ServerToClientHandshakePacket, PlayStatusPacket, StartGamePacket, DisconnectPacket, ResourcePacksInfoPacket and ResourcePackStackPacket, registered when run starts and removed when the login settles. A step that throws still fails the login. After it sends SetLocalPlayerAsInitialized it moves the session to play.
  • Phases: the login's steps accept login only. Credits and DimensionChange accept both, because they listened to every packet from the moment Client was built, and a change can arrive while the spawn answer is still being sent, before the session reaches play.
  • What changed in behavior: the listeners tracked a promise for every packet the session read, and now only a packet with a handler starts work. The LevelChunk handler returns nothing when no change is pending, so a chunk leaves nothing to wait on. The order of sends is unchanged, and SessionReplay.test.ts passes with no baseline touched.
  • The public API change, in the minor Changeset client-packet-handlers.md: the new registration, phase and context, DecodedPacket.packet, and constructors without a logger (new LoginSequence(session, auth, options), new DimensionChange(session, runtimeEntityId), new Credits(session, runtimeEntityId)). Emitting packet on a session by hand no longer drives the responders.
  • For the D2 close: isPacket and ClientboundPacketData stay. They narrow on the id alone, and the readers left watch the packet event: cli/commands.ts, which prints Text, and TeleportChunks.test.ts, both outside this package's row, and the real-server trip in DimensionChange.test.ts, which reads the whole sequence back in arrival order. Once those register handlers or narrow with decoded.packet instanceof, both can go.
  • Found: the published declarations used relative imports without an extension, so a consumer that resolves with NodeNext got no members from any package's root (#2985). It predated this package, and program head carries the fix.
  • Checks at D2g's head: pnpm run build, pnpm run typecheck, pnpm run lint:custom and pnpm run lint pass. The client suite passes, 14 files and 83 tests, its NetherNet tests included on that run (#2983 failed the four of them on the baseline run at e671fe72cb). SessionReplay.test.ts passes in the suite and alone. Client e2e spawn, dimensions and ender-dragon pass. The packed client and its workspace dependencies install in a scratch project, where a consumer that registers a handler typechecks with TypeScript 5.9.3 and skipLibCheck off under both NodeNext and Bundler, and a handler of the wrong payload is rejected. Rerun on program head 0b2c42f684: the build, typecheck, both lint gates, the client suite (14 files, 83 tests) and the three e2e scenarios pass.
D2 close ​
  • Deleted: network/handler/PacketHandler.ts, the shape that took the server, handle(packet, server, session). With it went the takes field of every handler and of ConnectionLaneHandler, ReadLaneHandler and InputLaneHandler, takesContext, and the second path in NetworkDispatcher and in appendHandler. The dispatcher hands a session's handler to HandlerContexts.run and nothing else. No plugin or other caller registered a handler in the old shape: git grep found appendHandler and registerHandler only in PacketHandlerRegistry and its test.
  • PacketHandlerRegistry.appendHandler takes and returns SessionHandlers. The merged handler runs in the first handler's phases and the stricter of the two lanes. An input-lane merge returns nothing to wait for, so a part that returns a promise has it handed to the step's outputs. A merge of connection or read parts is awaited part by part. It no longer takes the server, which is how a merged handler used to build its parts' contexts, so a merge now runs in the lane it declares and not as one asynchronous input.
  • noServerAccess.test.ts has no allowlist and asserts that no module under network/handler/ imports Server or calls getServer(), which meets the slice 6 target "No Server import in a handler". Lane.test.ts lost SERVER_MEMBERS, the Server entry of CONNECTION_IMPORTS and its parameter kind, and the check for a call on a Server. The other import patterns stay, since they guard a connection-lane handler against the world. KNOWN_WORLD_ACCESS keeps LoginHandler and ClientToServerHandshakeHandler, which still reach the world as the test shows.
  • The NetworkDispatcher tests registered doubles in the old shape. Those in the input lane now register a handler for a session in the play phase, because an input-lane double in a connection phase is a pair that production cannot have, and the harness awaits an input double through the step's outputs so the ordering tests still wait for it.
  • The step.defer calls are counted in noServerAccess.test.ts through reportProgress: 23 calls in 14 handler modules at this head. That is the work slice 9 has left, and the number is not gated. It is more than D2d's nine because the other packages deferred a world change, a command, a chat line or a disk write too.
  • The client lost isPacket and ClientboundPacketData. The CLI's connect command registers a TextPacket handler, TeleportChunks.test.ts registers three, and the portal trip in DimensionChange.test.ts reads the whole sequence back through a local payloadOf, which narrows decoded.packet with instanceof and reads getPacketData().
  • Not done: the workstation input slots (OpenBlockContainer.inputs) are still not logged. A logged slot needs a place in the snapshot, because "a snapshot plus the log reproduces the replay digest" (slice 7) replays the log onto a snapshot, and the player record keeps no workstation slots: the window is session state, nothing logs that it opened or closed, and inputs is a plain array that 18 lines in ItemStackRequestHandler, workstation.ts, tradeScreen.ts and ContainerCloseHandler index directly. Logging them would take a class like CraftingInput owned by the store player, a window that borrows it, and a rule for an undo that reaches a window already closed. Either the digest or the snapshot would then carry state they do not carry now, so this is not a change that leaves digests alone. It needs a decision about whether a workstation window is player state, which goes with D4's replica and slice 7's snapshot, and it stays where D3 left it.
  • Changesets: minor for @jsprismarine/prismarine (the old shape was public through its "./*" export) and for @jsprismarine/client (isPacket and ClientboundPacketData were public through its "./*" export). Both packages are below 1.0.0, where a breaking change is minor.
  • Checks at this head, on program head 204b9acc66: pnpm run build, pnpm run typecheck, pnpm run lint:custom and pnpm run lint pass. The Prismarine, client, parity and protocol handler suites pass (504 files, 4807 tests) apart from two NetherNet tests in Login.test.ts (#2983), which pass alone (11 tests); one run before it also failed three tests, among them enchantingTable.test.ts's seed test, which passes alone and on program head. Client e2e spawn, chat, movement, block-placement, dimensions, villager-trade and death-respawn pass. SessionReplay.test.ts passes with no baseline touched.

D3, the logged block writes, left these for D4 and later packages.

  • Every block write in a loaded chunk goes through World.writeBlockRuntimeId, which now appends a block field entry with the runtime id before and after it. The paths that reach it are setBlockRuntimeId, setBlockByName, setLoadedBlockRuntimeId and setLoadedBlockState. Physics, the engine systems through EngineWorld (fluids, fire, redstone, blasts), the block behaviors through worldBlockStorage (writes, piston moves and destruction), commands, the dragon fight's features, item interactions and a cooking station's lit swap all use them. No code outside world generation and chunk loading writes a loaded chunk around it. Layer 1 is never written by the world today; the entry kind carries the layer for when it is.
  • Every block entity write goes through writeBlockEntity, which hands World.blockEntityChanged the tags before the write, and every removal happens inside a block write. That covers content records (setBlockData), chest and furnace slots through openContainerSlots, loot fills, a piston's carried block entity, cooking stations and spawners. A block entity is logged as block-entity field entries addressed by its block position (blockentity/blockEntityLog.ts): data holds every tag but the position and the stacks, with Items kept as an empty list so the tags keep their order, and items.<slot> holds one stack. A value is the field's tags as network NBT bytes, or null. A block entity appears with data first and leaves with data last, and a block write logs the removal of its block entity before the block, so an undo, which runs newest first, always finds what it writes into. An Items list whose stacks do not each name a distinct slot stays whole in data.
  • A workstation that is a block entity (furnace, blast furnace, smoker, chest, barrel, hopper, shulker box) keeps its slots in Items, so they are items.<slot> fields. The enchanting table, anvil, stonecutter, smithing table and trade screen keep their input slots on the player's open window (OpenBlockContainer.inputs), because two players at one table each have their own, and ItemStackRequestHandler writes them. They are player state, not block state, so they are not block entity fields. They are not logged yet. The handler is D1's and D2's, and the natural home is an inventory-slot container of the player, as the cursor and the crafting grid are.
  • The log stays in EntityStore, which is per dimension and so per world. The world registers its inverse writer and its block update journal on the store when its first chunk enters, which is before any write into it and before any unit that could make one. A world-owned name for the log is a rename D4 can make if replication wants one; nothing depends on where it lives.
  • An undo writes the chunk without logging (Chunk.restoreBlockRuntimeId, which keeps the block entity, and Chunk.restoreBlockEntity, which files a block entity back in the place it held, since the chunk serializes them in that order and the digest reads it). It tells light, the portal index and the clients, as any block change does, and runs no block behavior and schedules no neighbor updates. The block update queue is in the replay digest, so BlockUpdateScheduler is a UnitJournal: the store marks it when a unit starts and rolls it back with the unit's span.
  • Not undone yet: scheduled block and fluid ticks that a write's neighbors queue (@jsprismarine/engine-systems' tick lists) and the light queue. Both are in the digest, so a unit that throws after a write whose neighbors scheduled a tick, or whose light changed, does not return the digest to where it was. The proxy tests use writes that touch neither. Block updates, world systems, cooking stations, spawners and the dragon fight run outside units, so a throw there keeps its writes, as before; ADR 0015 makes each block update a unit, which is still to do.
  • Writes between ticks, such as a command from a tick function, are counted as unlogged, as C1c set out. A chunk entering the world is still one chunk entry and carries no block entries.
  • Proxy tests: WorldChangeTick.test.ts checks that N block writes append exactly N entries, that an undone unit of block writes, of block entity changes and a removal, or of a placement leaves the replay digest unchanged, that the engine systems' write and a piston push are logged, and that undoing a second push restores what the first left. inventory/blockContainer.test.ts drives a real world: a stack moved into a chest is one items.<slot> entry, a lit furnace is one data entry a tick, and a world with an idle chest and furnace appends no block entries over twenty ticks.
  • Counts: ChangeLogCost reports blockEntries and blockBytes, and SessionReplay.test.ts reports them (non-gating). The scripted replay session places no blocks, so its 19 sampled ticks log 19 entries and 2212 bytes in all and no block entries. A replay scenario that builds and mines would give D4 a block figure.
  • For D4:
    1. Replication can send UpdateBlock from block entries and BlockActorData from block-entity entries, folding data and the items.<slot> fields back into one compound with withBlockEntityField, which is the form BlockActorData carries. The log's order already puts a placement's block before its block entity, which the client needs.
    2. WorldChangeSink's block-set and block-entity-set still go out for every write and every undo. D4 deletes them once it reads the log.
    3. A lit furnace appends one data entry a tick, and a spawner near a player one each tick its delay counts down. Each carries the block entity's tags without its stacks, before and after.
    4. A change that only reorders an Items list logs nothing, though the chunk's bytes change. Every writer in this server keeps stacks in slot order.
  • Checks at D3's head, on program head d2d36c1c2f: pnpm run typecheck, the Prismarine suite with JSP_CONTENT_VANILLA=1 (431 files, 4362 tests), the block-behaviors (27 files) and engine-systems (8 files) suites, SessionReplay.test.ts, the whole parity suite (53 files, the block and redstone families among them), and client e2e block-placement, mining-drops, lever-lamp, furnace, lit-furnace, farmland, edge-chunk-block-change and sub-chunk-block-changes pass. No replay digest changed.

D4: replication reads the change log ​

D4 is the rest of slice 7: replication reads each sealed tick of the change log, keeps a replica, computes each change's audience once and encodes once for each codec and compression group, and the simulation stops sending. It splits into six packages. D4a comes first, because every other package reads the log through what it builds. D4b and D4d then run side by side, D4c and D4e run side by side after them, and D4f closes the slice.

Where things stand at program head 204b9acc66:

  • The log is complete for blocks, block entities, entity fields, inventory slots and membership (D3, C1c, C4c). Writes between ticks are counted as unlogged and are not in it.
  • The replicator (network/NetworkWorldReplicator.ts) is a WorldChangeSink. The world pushes block-set and block-entity-set through worldChanged for every write and every undo, and the replicator collects them and sends them in the tick's flush phases, which run before the tick's log is sealed.
  • Entities reach clients through StoreActors.sync, which compares each actor with what it last sent: health, equipment (one equipment set built per store player a tick), the owner, the metadata inputs, the synced properties and the pose. showPlayerState throttles breath with breathWorthSending. A first sight calls spawnTo once for each viewer, which reads the live entity and encodes for that viewer.
  • Transient facts (sounds, particles, level events, actor events) are sink methods that send at once. Handlers relay through step.output, and PlayerSession sends to its own client and awaits the send in the middle of simulation code. 184 lines of PlayerSession.ts await something.
  • broadcastSharedBatches and the block flush already encode once for each codec and compression group. spawnTo, sendAddPlayer and the per-session sends do not.

The slice 7 items that earlier packages left, and the package that takes each:

ItemFromPackage
UpdateBlock from block entries, BlockActorData from block-entity entries folded with withBlockEntityField, and WorldChangeSink's block-set and block-entity-set deletedD3's "For D4" 1 and 2D4a
A lit furnace's and a spawner's data entry each tick, and an Items reorder that logs nothingD3's "For D4" 3 and 4D4a (sent as before; the reorder stays unlogged, as D3 found no writer that makes one)
StoreActors.sync's comparisons and the per-tick equipment comparisonC3, C4g, C4hD4c, over D4b's replica
The breath send, its throttle and the player state metadataC4d, C4h item 15D4c
The motion send for an impulse occurrenceC3, C4bD4d
The status effect clock and the actor record tags (persistentNbt), which the actor keeps outside the storeC3D4b gives them logged homes
Merchant offers, saved and hashed but not logged, because a trade changes the Merchant in placeC2D4b logs them, D4e sends them to the trader from the log
A stack network id after an undo: the slot has to be sent againC4cD4e
step.defer 1 of D2c: SetPlayerGameType, PlayerSession.setGamemodeD2cD4e
step.defer 8 of D2d: InventoryTransaction melee, meleeAttack awaits its soundsD2dD4e, after D4d makes the sounds occurrences
step.defer 4 of D2f: SetLocalPlayerAsInitialized, World.addPlayer and completeSpawnD2fD4e
Writes that stay in a handler's unit only because they come before the method's first await: setFlying, openMainInventory, releaseArrivalChunks, dropContents, setPlayerPosition, setSprinting, dropFromPlayer, looseD2c, D2dD4e removes the sends those methods await
Advancement triggers read from the log keep their timing (gameplay.combat-advancements)C3, "Checks to run"D4d
"Encode calls for one broadcast occurrence equal the number of codec and compression groups"TargetsD4d for occurrences, D4c for first sights
"Replication visits entities in proportion to log entries, not to the number of entities"TargetsD4a for blocks and block entities, D4c for entities
"A snapshot plus the log reproduces the replay digest"TargetsD4f
Outbound packets count toward the tick that produced themSlice 7, ADR 0006D4f
PackageDepends onFilesWhat is not mechanicalGate beyond the unit tests
D4a the block replicaD3New network/replica/BlockReplica.ts; the block and block entity parts of NetworkWorldReplicator.ts and WorldChangeSink.ts; WorldChange.ts, deleted; in World.ts, update and the block write, undo, resend and chunk paths; EntityStore.ts's handling of writes between ticks; a block-shown occurrence in ChangeEntry.tsA block entity's compound is folded from its fields, and the fields a tick did not change come from the replica, which takes a chunk's block entities when the chunk enters (the chunk's content arrives as an input under ADR 0012, so this is the input and not a read of the simulation). The seal moves ahead of the flush phases, so the flushes read a sealed tick. Block writes between ticks, such as a command from a tick function, are logged at the start of the next tick, which is when their packets went out before. resendBlock changes no state and becomes an occurrence. An undone unit's writes leave the log, so they send nothingProxy tests: reading a tick folds only the block entities the tick's entries name, whatever number the replica holds; a BlockActorData carries the bytes the block entity's own toNBT gives; a unit of block writes that throws sends nothing; a block write between ticks is sent in the next tick's flush. Replica entries and bytes through reportProgress. Replay digests; parity blocks.behavior, integration.block-behaviors; client e2e block-placement, mining-drops, lever-lamp, furnace, lit-furnace, farmland, edge-chunk-block-change, sub-chunk-block-changes
D4b the entity replicaD4aNew network/replica/EntityReplica.ts; the metadata projection helpers moved out of entity/data/storeActors.ts into a module the actor and the replica share; the status effect clock and persistentNbt in storeActors.ts; merchant offers in entity/data/trading.tsEvery field a client is shown needs a logged home before the replica can be built from the log alone: the status effect clock, the actor record tags and the offers are not logged today. The replica is folded from a membership entry's saved form and the entity and slot entries after it, so the projection has to run from plain data. Logging the offers changes no state, so no digest movesProxy test: for every entity in the replay scenarios, the replica folded from the log equals the projection the actor makes from live state, tick by tick. Replica entries and bytes through reportProgress. Replay digests; parity entities.addactor, gameplay.brain-effects
D4c entity replicationD4bentity/data/storeActors.ts, entity/data/storePlayers.ts, world/ShownActor.ts, the entity parts of NetworkWorldReplicator.ts and WorldChangeSink.ts (reconcile, entityMoved, flushMovement, entityAdded, entityRemoved, the attribute, metadata, equipment and status effect changes), the tracking phase in World.ts, and PlayerSession.sendAddPlayerStoreActors.sync and its comparisons go: moves come from position entries, health and metadata from the replica, equipment from inventory-slot entries, a removal from a leave entry. A first sight is answered from the replica and encoded once for each group, so spawnTo stops reading the live entity. Breath goes out from vitals.air entries with the same throttle. The tracking phase moves after the seal. Entity writes between ticks are carried into the next tick as D4a carries block writesProxy tests: with N entities and k of them written in a tick, replication visits k; a first sight to V viewers in G groups encodes G times. Replay digests; parity entities.addactor, integration.client-packets; client e2e movement-fanout, movement-rotation, look-direction, gait, summon-entity, attack-entity, damage, item-drop-pickup, natural-spawn-placement, ender-dragon
D4d occurrencesD4aworld/changelog/ChangeEntry.ts's occurrence kinds; the transient members of WorldChangeSink.ts and their parts of NetworkWorldReplicator.ts (blockSound, namedSound, namedParticle, worldEffect, actorEffect, actorSound); the methods in World.ts that call them; the relaying handlers (Animate, ActorEvent, Emote, LevelSoundEvent); entity/data/live.ts' impulse sendEach transient fact becomes an occurrence that replication encodes once for each group, after the seal. An occurrence in a unit that throws is removed with it, so a sound for an undone action no longer plays. Advancement triggers that observers read from the log have to fire at the same point as the inline triggers didProxy test: one broadcast occurrence to V viewers in G groups encodes G times. Parity gameplay.combat-advancements; client e2e attack-entity, player-knockback, damage, eating, lever-lamp, block-placement
D4e the owner's viewD4b, D4dnetwork/PlayerSession.ts; the three handlers with slice 7 step.defer calls (SetPlayerGameType, InventoryTransaction, SetLocalPlayerAsInitialized); the inventory and trade screen sendsThe simulation stops awaiting sends to the player's own client. Slots reach their owner from inventory-slot entries, and a slot whose write was undone is sent again, because the stack's network id changed. The trader is sent offers from the log. The three deferred pieces run in the handlerThe step.defer count lowered by three, and the awaits reachable from World.update through reportProgress. Replay digests; client e2e spawn, rejoin, death-respawn, villager-trade, recipe-book, eating, attack-entity, teleport
D4f the closeD4c, D4eWorldChangeSink.ts reduced to the sealed tick and what is left; the replay recorder; a snapshot test beside SessionReplay.test.ts; the docs the slice invalidatesA packet replication sends counts toward the sealed tick it read, not the tick the transport framed it in (ADR 0006). A snapshot plus the log reproduces the replay digest only if everything the digest hashes is logged or held in the snapshot: D3 found that scheduled block and fluid ticks, the light queue and the block update queue are hashed and not logged, so this package logs them or puts them in the snapshot and says whichEverything in the gate for each slice, the full client e2e suite, and the three targets gating

Rules that keep the packages apart:

  • D4a lands first. The others build on its sealed-tick reader, its network/replica/ module and its carrying of writes between ticks into the next tick.
  • NetworkWorldReplicator.ts, WorldChangeSink.ts and World.ts are shared by section: D4c owns the entity members and the tracking phase, D4d the transient members and the methods that call them. Neither moves the other's members.
  • D4c owns storeActors.ts and storePlayers.ts; D4e owns PlayerSession.ts. sendAddPlayer is D4c's, and D4e does not edit it.
  • No D4 package edits the D2 close's files (PacketHandler.ts, SessionHandler.ts, handlerContexts.ts, NetworkDispatcher.ts, PacketHandlerRegistry.ts, noServerAccess.test.ts, Lane.test.ts) until the close has landed.
  • Each package adds its own Changeset. The sink is public through Prismarine's "./*" export, so a package that changes its members adds a minor one.
D4a: the block replica ​
  • Blocks reach clients only from the log. WorldChangeSink.worldChanged and world/WorldChange.ts are gone. A world hands its sink each sealed tick through tickSealed, from EntityStore.onSealed, which World.blockLog installs, and World.update calls it so the sink reads every tick. The sink also hears chunkEntered(x, z, blockEntities) and chunkLeft(x, z) from enterChunk and the unload.
  • network/replica/BlockReplica.ts holds the logged fields of every block entity in the loaded chunks, as network NBT bytes by field. chunkEntered replaces a chunk's record with the fields of the block entities it arrived with. read(sealed) notes each block's last runtime id (layer 0 only, the one the world writes) and writes each block-entity field into the replica, passing over a field in a chunk it does not hold. compound(at) folds data and the items.<slot> fields with withBlockEntityField and passes the result through BlockEntityRegistry.fromNBT(...).toNBT(), so the tags come out in the order the block entity's own toNBT writes them, which is the order clients were sent before. NetworkWorldReplicator keeps one replica per world and collects the read into the same pending maps the flushes used, so the flushes are unchanged.
  • The sink is not handed the chunks loaded before it was attached. The level attaches it straight after it builds the world (Level.ts, onWorldBuilt), before any chunk loads.
  • The seal moved. World.update seals after the tracking phase and then runs replicateTick: the block changes, the block entities, the movement, the time, the chunk unloads and the outbound flush, in the order they ran before. A tick that throws is sealed and sends nothing, and the replicator keeps what it read for the next flush, as its pending maps did before.
  • EntityStore holds block and block-entity entries and block-shown occurrences made between ticks and appends them when the next tick opens, ahead of that tick's own entries (isBlockEntry in ChangeEntry.ts names them). They are no longer counted as unlogged. Other writes between ticks still are, which D4c changes for entities.
  • World.resendBlock logs a block-shown occurrence carrying the runtime id it shows, since the replica keeps no blocks.
  • What changed on the wire: a unit that throws and is undone no longer sends the blocks it restored, because its entries leave the log. The writes it undid were never sent, since the flush runs after the tick. Nothing else changed: the scripted replay's digests are unchanged and the eight block scenarios pass.
  • Proxy tests. network/replica/BlockReplica.test.ts: holding 1 or 1000 block entities, a tick of three entries (one slot and two blocks) reads 3 entries and folds 1 compound, so the work follows the log and not the replica's size. world/WorldChangeSink.test.ts, over a real world and store: a block write reaches the sink in the tick that made it, a unit of two block writes that throws hands the sink nothing, a write between ticks arrives with the next tick, a resend is a block-shown occurrence, a chunk's block entities are handed over as it enters, and a chest folded from its fields gives the bytes of the chunk's own toNBT. WorldTickPhases.test.ts checks that the seal comes after reconcile and before flushBlockChanges. EntityStore.test.ts checks the carrying and that an entity write between ticks is still counted as unlogged.
  • testing/sealedBlocks.test.helper.ts builds block entries, sealed ticks of them, a chest's tags, a sink that reads into a replica (replicaSink) and sealBlockKinds for a store-backed world double. The replicator tests, the cooking, spawner, break, interaction and block entity tests read the log through them. The world doubles built on the prototype now set changes, which the constructor would have.
  • Counts: NetworkWorldReplicator.blockReplicaCounts() reports the block entities held, their bytes, the entries read and the compounds folded, and SessionReplay.test.ts reports them (non-gating). The scripted replay holds 0 block entities, reads 1 block entry and folds none. The entry is the water block its script places from the server's tick event, between ticks, which D3 counted as unlogged and the next tick now logs.
  • For the later packages: the transient sink members (blockSound, namedSound, namedParticle, worldEffect, actorEffect, actorSound) and the entity members are unchanged. replicateTick is where D4c moves the tracking phase. The block entity flush still sends one BlockActorData per position through sendAround; D4d's encode-once count can take it with the occurrences.
  • Checks at D4a's head, on program head 204b9acc66: pnpm run typecheck, pnpm run lint:custom and pnpm run lint pass. The Prismarine, block-behaviors, engine-systems and parity suites with JSP_CONTENT_VANILLA=1 pass together, 525 files and 5272 tests, at load average 9 to 13. SessionReplay.test.ts passes with no baseline touched. Client e2e block-placement, mining-drops, lever-lamp, furnace, lit-furnace, farmland, edge-chunk-block-change and sub-chunk-block-changes pass.
D4b: the entity replica ​
  • network/replica/EntityReplica.ts holds every entity of a world as plain data folded from the log. A membership enter entry starts an entity from the saved form it carries (entity/data/savedForm.ts reads the form as logged fields, named the way field entries address them), each entity and inventory-slot entry after it writes one field or slot over that form, and a leave entry drops it. An entry for an entity the replica does not hold is passed over, which is what happens to an entity that entered between ticks, because its entering entry is not logged. NetworkWorldReplicator.tickSealed reads each sealed tick into one replica per world, after the blocks.
  • EntityReplica.view answers an EntityView (entity/data/actorView.ts): the pose, velocity, on_ground, the metadata projection, health with its maximum, the synced properties, the status effects and the equipment. entityViewOf builds the same view from a live entity. The metadata projection is split in actorMetadata.ts into what a component set decides (componentMetadataFacts, over a ComponentView) and what the entity's state decides (ActorMetadataState, plain values), joined by projectActorMetadata. The replica resolves a component set from the entity's type and its logged groups, base components first and then each group in order, as EntityInstance does, and caches one view per type and group list. @jsprismarine/entity-goals' navigationProfile takes any NavigationSource for this. healthAttributesOf moved out of storeActors.ts into actorView.ts.
  • Logged homes. The status effect clock is the body field statusEffectClock, stored as its reading at one update ({ age, tick }) and written at an entity's first update and after a gap in its updates, so a steady entity logs nothing for it; a new entity starts the clock again, as a new store actor did. The actor record tags are the body field actorTags, network NBT bytes (nbtBytes.ts, shared with the block entity log), and StoreActor.persistentNbt is an accessor over them that answers a new compound on each read. The online owner is the body field owner. The two conditions a client is shown, in_water and on_ground, are logged as condition:<name> as they change (WatchedSet). A trader's offers are the field merchant, written after a draw, a trade and a restock in the form a save writes. A trade screen trades through EntityRuntime.tradeSource, which reads the merchant on each call, so an undone trade, which builds the merchant again from the logged offers, is what the screen shows next. The saved form also carries the owner, the shown conditions, the record tags and each attribute with its bounds, which the record does not hold.
  • The vanilla proxy test found one more gap: a body field and samePlain compared numbers with ===, so a pitch written from 0 to -0 was not logged while clients were sent -0. Both compare with Object.is now.
  • Nothing changed on the wire, and StoreActors.sync still sends. A unit that throws now also undoes the offers, conditions and owner it changed. The world and outbound digest series of the scripted replay are byte for byte those of program head 52f4016c9b over its 19 sampled ticks, checked by writing both series out from the two checkouts, so no baseline moved.
  • Proxy tests. network/replica/EntityReplica.test.ts: a fixture entity through thirteen shown writes (name, color, fire, water, owner, leash, a synced property, a group change, health, a status effect, a beam, a fuse and the holder's removal) and three wanderers, 30 ticks, with the folded view equal to the live one after every tick; holding 3 or 300 entities, a tick that names two of them reads 2 entries and reports 2 changed entities; an entity whose entering entry was never read has no view. With JSP_CONTENT_VANILLA=1, 125 entities of the 131 vanilla types (the rest refuse a bare spawn) over 20 ticks match after every tick, and the replica reports 125 entities in 150771 bytes, 2657 entries read and 2618 views built. SessionReplay.test.ts holds every entity's view against the replica as each sampled tick seals: 38 views over 19 ticks, no divergence, and a replica of 2 entities in 2512 bytes with 17 entity entries read, through reportProgress. PlayerChangeLog.test.ts' warm-up tick now includes the player's first update, which writes the clock once.
  • For D4c:
    1. EntityReplica.read returns EntityTickChanges: the entities that entered and left, and the fields and slots each held entity had written. tickSealed discards it today; D4c keeps it for its sends. entityView(id) and entityReplicaCounts() are the replicator's public members.
    2. An entity that enters between ticks never reaches the replica. Carrying entity entries into the next tick, as D4a carries block entries, fixes it.
    3. EntityView leaves out what a player's own state decides: breath (vitals.air, vitals.breathing), sprinting, flight from the game mode, the head yaw, and the session's skin and name. A player's own fields are logged by PlayerSystems but are not in the saved form, so the replica holds them only once written; D4c has to seed them or put them in the saved form. A player's equipment comes from its inventory, which the replica holds as inventory-slot entries (EntityReplica.slot).
    4. The teleport bit of a move (MobBody.teleportPending) is not logged.
    5. A store actor sends the maximum health it was built with, while the view carries the logged maximum (#2995). Sending health from attribute:health entries fixes it.
    6. A leash held by a player resolves through the world's sessions (playerIdOf), as EntityRuntime.leashHolderIdOf does through its host.
  • Checks at D4b's head, on program head 52f4016c9b: pnpm run typecheck, pnpm run lint:custom and pnpm run lint pass. The Prismarine and parity suites with JSP_CONTENT_VANILLA=1 pass together, 491 files and 4744 tests, at load average 12 to 27, including entities.addactor and gameplay.brain-effects; a first run at load average 15 to 27 failed three enchanting table tests that pass alone. SessionReplay.test.ts passes. Client e2e spawn, villager-trade and eating (a golden apple's effects) pass.
D4c: entity replication ​
  • Entities reach clients only from the log. StoreActors.sync, its per-tick comparisons and the per-tick equipment comparison are gone, with World.flushEntityAttributes and the sink's entityAttributesChanged, entityMetadataChanged, entityEquipmentChanged, entityAdded and entityRemoved. network/replica/EntityReplication.ts reads each sealed tick into the replica from tickSealed and sends it from the sink's new flushEntities, which World.replicateTick runs first after the seal. It shows the entities that entered in the order of the entries: an arriving player is shown the world, then each arrival is offered to everyone else. Then it visits the entities the tick wrote, in the order the replica took them in, which is the store's, and sends each one's equipment and metadata, then their health, and queues their moves for flushMovement. A leaving entity waits for the sink's flushDepartures, which drops it from every client, unless it enters again later in the same flush, when it leaves in order.
  • Where each kind comes from: a move from position, yaw, pitch and teleports entries; metadata from the replica's projection, with a store player's sprint, flight and breath; synced properties and health (with its maximum) from the replica; a mob's equipment from its equipment field, and a player's from the inventory-slot entries of its hotbar, armor and off hand and its inventory.handSlot field. After the seal, World.replicateTick runs flushEntities, D4d's flushOccurrences, flushDepartures, D4e's flushOwnerViews, the tracking pass, then the block changes, the block entities and the movement. An occurrence about an entity that entered reaches a client after its AddActor, and one about an entity that left, such as a killing blow's hurt flash (D4d's "For D4c" 1), before its RemoveActor. The tracking pass hides whatever the replica no longer holds, so it follows the departures.
  • Each send still goes out only when it differs from what the entity's viewers were last sent, over the visited entities alone. That keeps the packets the old comparison sent: the 1e-4 block move threshold, the breath's breathWorthSending throttle, a metadata object written over as the actor's was (so a property that leaves the projection stays as clients hold it), and equipment compared as encoded bytes.
  • A first sight is built from the replica (network/replica/firstSight.ts), so spawnTo reads no live entity. Tracking reads ShownEntity values from WorldChangeSink.shownEntities, and each one's spawnTo encodes its packets once for each entity, codec and compression group in a flush. An entity that entered is shown as it entered, before the entries after its entry, so the AddActor and the updates that follow in that tick are the ones a store actor sent. PlayerSession's sendAddActor, sendAddItemActor, sendAddPlayer, sendEquipmentFor and sendHeldItemFor are gone, and its armor, status effect and ability sends use the same builders.
  • D4b's five items:
    1. EntityReplica.read reports the memberships in entry order, each entering entity as it entered, and the fields and slots an entity that entered wrote after its entry.
    2. EntityStore carries every state entry made between ticks into the next tick, and EntityRuntime.remove now records a leaving entry between ticks too. The client e2e summon-entity found that gap: a console /kill runs between ticks, and the pig stayed on screen.
    3. A store player's head yaw is the logged field look.headYaw, and its entity enters the log with a shown form beside its saved form (entity/playerShown.ts): head yaw, hand slot, sprinting, game mode, flying, air, breathing, and the hotbar, armor and off-hand slots. Identity (UUID, name, device, platform chat id) comes from the store player and the operator flag from the permission service, as before.
    4. A teleport writes the logged body field teleports, a count; a tick that writes it sends the move with the teleport bit.
    5. Leashes resolve players through the world's sessions, and every leashed entity is projected again in a flush that had a membership.
  • Two more logged homes: an inert actor's saved identifier is the saved form's shownAs (EntityRuntimeOptions.shownAs), and an inert TNT's fuse is its body's fuseTicks in place of a hand-set metadata value.
  • A store actor's metadata and attributes are read from the store when asked; what remains reads them: a player's own client, saves and the replay digest. The live metadata leaves out the leash holder, which only other clients are shown.
  • What changed on the wire: health carries the maximum the store holds (#2995). AddActor, RemoveActor and the first sights of a spawn leave after the seal instead of mid-tick, in the same tick. A player crossing a chunk mid-tick is shown entities as the last sealed tick left them. Metadata is sent for any entity a tick wrote, where the old pass projected only entities in simulated chunks. A game mode change and a revival send the player's metadata from the log instead of from PlayerSession. The scripted replay's world and outbound digest series are byte for byte those of program head 70d6c63830, so no baseline moved.
  • Proxy tests in network/NetworkWorldReplicatorEntities.test.ts, over a real store's sealed ticks: holding 3 or 300 entities, a tick that writes 2 of them visits 2; an actor-event and a removal in one tick reach a tracker as the event's frame and then the drop, and the other way round when the departures run first; a first sight to 5 viewers in 3 codec and compression groups encodes 3 times, and the 3 viewers of one group get the same bytes; a raised maximum health goes out as the new maximum (#2995); an entity removed between ticks is dropped with the next tick. entity/data/storePlayers.test.ts holds a player's AddPlayer, abilities, armor, moves and breath throttle to the old packet sequence (breath writes of 249, 245, not breathing, sprinting and survival send 1, 0, 1, 1 and 1 SetActorData), and the eye of ender test still sends 2 moves in 3 ticks. EntityReplicationCounts (visited, firstSightEncodes, firstSights) is the replicator's entityReplicationCounts.
  • For D4e: status effects still go out when an actor changes them (StoreActor.announceStatusEffect, statusEffectChanged), not from the statusEffects entries, because the entries carry the whole list each tick and no change kind. entityMoved stays for the moves PlayerSession and MovePlayerHandler make, and a player's own client still reads the live StoreActor.metadata.
  • For D4f: the tracking phase still plans over every entity the replica holds, on its interval. The first-sight cache lives for one flush and is cleared by each read. SessionReplay.test.ts does not report entityReplicationCounts yet.
  • PlayerSessionOwnSends.test.ts (D4e) no longer finds a SetActorDataPacket among the packets setGamemode queues: the player's metadata goes to it and its viewers from the logged game mode after the seal. testing/changeTicks.test.helper.ts' replicateEntities runs the four post-seal phases in the world's order for tests that drive a replicator.
  • Checks at D4c's head, rebased onto program head 25cdcbb46d: pnpm run typecheck (49 tasks), pnpm run lint:custom and pnpm run lint pass; lint's one warning is the existing unused Tag in packages/leveldb/src/db/VersionEdit.ts. The Prismarine and parity suites with JSP_CONTENT_VANILLA=1 pass together, 495 files and 4793 tests, at load average 11 to 15. SessionReplay.test.ts passes, and its world and outbound digest series are byte for byte those of 25cdcbb46d. Client e2e spawn, movement-fanout, summon-entity, attack-entity, player-knockback, damage, item-drop-pickup, death-respawn, villager-trade, eating and rejoin pass together, and dimensions passes alone; it fails when it runs soon after damage in a selection, because it asserts full health on arrival (#2997). Before the rebase, on 70d6c63830, movement-rotation, look-direction, gait, natural-spawn-placement, natural-spawn-under-canopy, ender-dragon, teleport and fall-damage passed as well.
D4d: occurrences ​
  • Every transient fact is an occurrence in the change log, and replication sends it after the seal. ChangeEntry.ts has block-sound, named-sound, named-particle, world-effect, actor-sound, relayed-sound, and the existing actor-event, animate, emote and impulse kinds, extended with what replication needs. sound was declared and never written, and is now block-sound.
  • WorldChangeSink lost blockSound, namedSound, namedParticle, worldEffect, actorEffect and actorSound, and gained the tick phase flushOccurrences. tickSealed keeps the sealed tick's occurrences in log order, and World.replicateTick runs flushOccurrences first, ahead of the block changes, so a break's particles still reach a client ahead of its block change, as when they were sent the moment they happened. The world methods keep their names and signatures (sendWorldEvent, sendActorEvent, sendActorSound, hitBlock, playNamedSound*, spawnNamedParticle*) and log through a private World.occurred. A position of another world is refused there with the verbose line getViewers gave, since the log has no world on a position.
  • The four relaying handlers log in their own unit and no longer pass a send to step.output: World.relayAnimation, World.relayEmote and World.relaySound are new, and ActorEvent goes through sendActorEvent. The emote's debug line no longer counts its audience, which the handler no longer knows. The client sound relay rebuilds its packet from the fields the client sent, which are the bytes the relay always carried, since the send path encodes from fields.
  • The impulse: host.playerImpulse and its live.ts send are gone, and replication sends SetActorMotion to the owner from the impulse occurrence through PlayerSession.sendMotion.
  • EntityStore holds an occurrence made between ticks for the next tick, as it holds block entries (isCarriedEntry in ChangeEntry.ts). An action-rejected is a record and not a send, so it is still counted as unlogged.
  • What changed on the wire. An occurrence logged in a unit that throws leaves with the unit and is not sent. An occurrence made between ticks, such as a sound from a tick function, goes out with the next tick's flush. A world with no entity runtime has no log and sends none of them, as D4a already had it for blocks. Inside a tick, the occurrences now follow the packets the simulation still sends inline (attributes, metadata, the spawns and removals, the owner's own state) rather than sitting between them. One consequence is visible in a capture: an ActorEvent for an entity removed later in the same tick, such as the hurt flash of a killing blow, finds no trackers at the flush, because the removal went out inline and the trackers forgot the entity. The client removes the entity in that tick either way, so nothing on screen changes. animate and emote reach their audience through one shared batch for each codec and compression group instead of one send for each viewer.
  • Advancement timing: the advancement triggers and the plugin entityDamage and entityDeath events still fire inline from EntityHostFacts.damageCommitted. No observer reads them from the log, so their timing did not move, and gameplay.combat-advancements passes unchanged.
  • Proxy tests. network/NetworkWorldReplicatorOccurrences.test.ts: one occurrence of each of the nine broadcast kinds, sent to six viewers in three codec and compression groups, is encoded 3 times, 2 of them for the first protocol version, and every viewer of a group gets the same bytes (the slice 7 target for occurrences). The same file checks each kind's audience and packet, that nothing is sent before the flush and each occurrence once, and, through the real LevelSoundEventHandler, that a relayed client sound goes out with the bytes the client sent. world/WorldChangeSink.test.ts, over a real world and store: an occurrence in a unit that throws is not in the sealed tick, one between ticks arrives with the next tick, and one at a position of another world is refused. EntityStore.test.ts checks the carrying, and WorldTickPhases.test.ts that the flush runs after the seal and ahead of the block changes. PlayerChangeLog.test.ts checks that a shove sends no motion until replication reads the sealed tick.
  • testing/occurrences.test.helper.ts builds a prototype world double over a real store that reads back the occurrences of each tick (occurrenceWorld), and sealOccurrences hands a sink a sealed tick of occurrences. The world doubles in live.test.ts, serverSource.test.ts and interaction.test.ts read sounds and actor events from the sealed tick.
  • The scripted replay logs 20 entries and 2350 bytes over its 19 sampled ticks (SessionReplay.test.ts, non-gating). It holds no occurrence: its session makes no sound the server logs.
  • For the later packages:
    1. D4c: the actor event flush reads session.tracks at the flush, after an inline removal. Reading a leave entry in log order, after the occurrences before it, sends a killing blow's ActorEvent to the trackers again. sendToTrackedEntity now takes a Pick<ShownActor, 'getRuntimeId'>.
    2. D4e: meleeAttack's sounds and particles (Combat.ts) are logged synchronously now. sendActorSound, sendWorldEvent and sendActorEvent are still async and resolve without waiting on anything, so step.defer 8 can drop the awaits and run in the handler. hitBlock still awaits getBlockState before it logs.
    3. The block entity flush still sends one BlockActorData per position through sendAround, which already encodes once for each group; it has no proxy test of its own.
  • Checks at D4d's head, on program head 52f4016c9b: pnpm run typecheck, pnpm run lint:custom and pnpm run lint pass. The Prismarine and parity suites with JSP_CONTENT_VANILLA=1 pass together, 491 files and 4752 tests, at load average 20 to 33. gameplay.combat-advancements passes. SessionReplay.test.ts passes with no baseline touched. Client e2e attack-entity, player-knockback, damage, eating, lever-lamp, block-placement, movement-fanout and mining-drops pass. The NetherNet loopback tests in Login.test.ts, ticker.test.ts's timeout test and three enchantingTable.test.ts seed tests each failed in one full run under load and passed alone and in later full runs.
D4e: the owner's view ​
  • The simulation no longer awaits a send to a player's own client in the three slice 7 deferred pieces and the methods they reach. A send queues its packet before its first await (MinecraftSession.sendDataPacket), so a method that calls its sends in turn and hands their promises to allSent (PlayerSession.ts) has written everything and queued every packet when it returns. Awaiting each send in turn queued the next packet, and made the writes after it, only once the transport framed the last. Converted: setGamemode, setFlying, stopMining, announceSettings, sendGameTypeChange, sendAbilityChange, completeSpawn, sendInventory, sendArmorFor, sendAttributes (which now clears the dirty attributes as it queues them), sendStatusEffects, setPlayerPosition, openMainInventory and releaseArrivalChunks. They keep their Promise<void> return, which now only says when the transport framed the packets.
  • The three step.defer calls run in their handlers. SetPlayerGameType calls setGamemode in its unit and hands the promise to step.output. SetLocalPlayerAsInitialized calls World.addPlayer, now synchronous, and completeSpawn in its unit. InventoryTransaction's melee calls meleeAttack, which returns its boolean directly: the blow goes through the store's damage pipeline, and its sounds and particles are occurrences. A target that has left its world's store takes nothing, which is what the pipeline answered for it before. loose is synchronous the same way, so a bow release puts the arrow in the world in its unit. The step.defer count is 20 calls in 12 handler modules, down from 23 in 14 at program head f8593f7826.
  • D2c's and D2d's writes that stayed in a unit only because they came before the first await: setFlying, openMainInventory, releaseArrivalChunks and setPlayerPosition have no await now. setSprinting had none. World.dropContents, dropFromBlock and dropFromPlayer drop through a synchronous spill; they make no sends. loose is above.
  • Where each owner send comes from now:
    1. The game mode change, the join's spawn state, a move, the inventory window and a released arrival: the input's handler, queued in its unit, as before. The packets and their order are the same; PlayerSessionOwnSends.test.ts asserts the order, and the same assertions pass against the program head's PlayerSession.ts with the calls awaited.
    2. A trader's offers: replication. tickSealed collects the traders whose merchant field the sealed ticks wrote, and the new sink phase flushOwnerViews, which World.replicateTick runs after flushOccurrences, sends sendTradeOffers to every session with a trade screen open on one. ItemStackRequest no longer sends them after a trade. The prices still come from the screen's TradeSource at the flush, since the per-player context (cures, the hero effect) is not in the log.
    3. A slot whose write was undone: replication. EntityStore logs a slot-shown occurrence for each slot an undone span wrote, once and in the order the span first named it, in the span around the undone one, so undoing that span too names the slot again (ChangeLog.revertSpan returns the entries it removed). flushOwnerViews sends each to the player that carries it through PlayerSession.sendOwnSlot, which assigns the restored stack its new network id and sends InventorySlot for the inventory, the armor, the off hand and the cursor. The ender chest and the crafting grid have no window the client always holds, so nothing is sent for them.
  • What changed on the wire. A trade's UpdateTrade leaves after the seal in the same tick, not straight after the ItemStackResponse. A level up or a restock while the screen is open now reaches it. A slot written back by an undo is sent to its owner. A failure in setGamemode, completeSpawn, a melee hit or a bow release now undoes the whole input, where it used to undo only the deferred piece. The scripted replay's digests did not move and no baseline was touched; it logs 21 entries and 2496 bytes, as at the program head.
  • Proxy tests. network/PlayerSessionOwnSends.test.ts, over a real session: setGamemode has written the mode and queued its 8 packets before it returns, and awaiting adds none; completeSpawn queues 18 packets in the old order and has cleared the dirty attributes; an undone slot leaves one slot-shown entry and is sent under a network id other than the one it had. entity/data/EntityStore.test.ts: a unit that wrote one slot twice and another once leaves 2 occurrences, a nested undo inside an undone unit leaves 2, and a unit that finishes leaves none. network/NetworkWorldReplicatorOwnerViews.test.ts: 3 merchant entries over two ticks send the offers once to each of the 2 players trading with that trader and 0 times to the others, and slots go to their owner alone in log order. WorldTickPhases.test.ts places flushOwnerViews after flushOccurrences. network/PlayerSessionAwaits.test.ts walks calls on this from World.update and from PlayerSession.updatePlayer and reports the awaits it reaches through reportProgress: 27 in 50 World methods, unchanged, and 53 in 48 PlayerSession methods, down from 64; PlayerSession.ts holds 145 awaits, down from 184. It fails if a method the input handlers run in their unit gains an await.
  • For D4f:
    1. inventory-slot entries reach the owner only for an undo. The client learns the result of its own stack requests from the ItemStackResponse, so sending a slot for every entry would send each request's slots twice. Sending the rest from the log needs replication to know which slots a response answered. Until then the whole-inventory resends (the commands in command/host/, a pickup in live.ts, the item interactions in item/interaction.ts) still call sendInventory from simulation code, without awaiting it.
    2. World.sendActorSound, sendWorldEvent and sendActorEvent are still async and resolve without waiting, so meleeAttack and loose hand their promises a .catch. Making them synchronous removes that.
    3. PlayerSession.ts still holds 145 awaits; most are chunk streaming, the dimension change and the respawn, which are slices 8 and 9.
  • Checks at D4e's head, on program head f8593f7826: pnpm run typecheck, pnpm run lint:custom and pnpm run lint pass; lint's one warning is the unused Tag type in packages/leveldb/src/db/VersionEdit.ts, which this package does not touch. The Prismarine and parity suites with JSP_CONTENT_VANILLA=1 pass together, 495 files and 4775 tests, at load average 36 to 63. SessionReplay.test.ts passes with no baseline touched. Client e2e spawn, rejoin, death-respawn, villager-trade, recipe-book, eating, attack-entity, teleport and mining-drops pass.
D4f: the close ​
  • The target "a snapshot plus the log reproduces the replay digest" gates. A third run of the scripted session in SessionReplay.test.ts stops at the tick before the sample, which is the snapshot, and runs no tick after it. It writes each sampled tick of the first replay's log onto that world through EntityStore.replay, and after each one the digest, taken with the sealed tick's header, equals the one the replay had as that tick sealed: 20 ticks, the snapshot's own included. Entity ids are mapped from the replay's players to the snapshot's, since runtime ids are process-local. A chunk entry takes the input the replay took: the snapshot asks for the chunk and admits it when the entry comes, and a chunk that left is taken out by the new World.replayChunkLeave, which needs only the position. The sample enters 6 chunks and unloads at least one. Leaving out the entity entries makes 17 of the ticks diverge, so the comparison bites.
  • What the digest hashes and the log did not hold, and where each went:
    1. The block update queue: logged. Each queue, move to an earlier tick and removal is a block-update field entry holding the due tick (BlockUpdateScheduler.observer). Applying the entries in order keeps the queue's processing order, which the digest hashes, and the log now undoes a unit's updates, so BlockUpdateScheduler is no longer a UnitJournal; UnitJournal and EntityStore.addUnitJournal are gone.
    2. The scheduled block and fluid ticks: logged. Each tick queued and taken off is a scheduled-tick field entry holding its trigger tick, priority and order (TickScheduler.observe, TickList.restore in @jsprismarine/engine-systems). This also closes D3's "Not undone yet" for scheduled ticks: an undone unit takes back the ticks its writes queued. WorldChangeTick.test.ts covers both queues, since the scripted session's water reacts through block updates that drain in the tick that queued them and queues no scheduled tick.
    3. The level clock, the weather, the game rules, the random tick generator, the depth of the light queue and the chunk loads in flight: in the sealed tick, as its header (world/changelog/TickHeader.ts, World.tickHeader). Each moves every tick whether or not anything was written, or belongs to the level that every world shares, or is work derived from block writes, or is an input in flight, so an entry for each would break "an idle world appends no entries". World.replayStateDigest(header) hashes a given header in place of the world's own, with the same bytes as before. The light queue is still not undone with a unit; its depth is in the header, so a unit that throws after a write that queued light leaves the depth as the write left it, as D3 found.
  • Outbound packets count toward the tick that queued them (ADR 0006). MinecraftSession reads the tick when a send queues a packet, not when the transport frames it, so a packet replication sends counts toward the sealed tick it read. The scripted replay frames every such packet inside its tick, so its outbound digests did not move; MinecraftSessionOutboundCodec.test.ts checks a packet framed after its tick ended.
  • WorldChangeSink carries the sealed tick and its flush phases, and lost statusEffectChanged: a status effect change is a status-effect occurrence, sent after the seal to the trackers with one encode for each group and to the entity's own client. What it still hears outside the sealed ticks, and why, is in its comment: a chunk entering and leaving with its block entities (the content arrived as an input), entityMoved for a player's own moves (a head turn alone, the pose resent at a spawn and a respawn's teleport bit, which the log's pose comparison does not show), and the time and a game rule (the level's, which no world's log holds).
  • Counts through reportProgress from SessionReplay.test.ts, not gated: replication read 18 entries (1 block, 17 entity), visited 6 entities and encoded 2 first sights for 2 first sights. The change log holds 35 entries and 4260 bytes over the 19 sampled ticks, up from 21 entries at program head 847fe2cf32: the block updates the water queues are entries now. The world and outbound digest series are byte for byte those of 847fe2cf32, checked by writing both series out from the two source trees, so no baseline moved.
  • Leftovers done: D4e's 2, World.sendActorSound, sendWorldEvent and sendActorEvent return nothing, and World.sendStatusEffect too, so their callers hand no promise a .catch; the actor event relay logs in its handler's unit with no output. D4c's status effects go out from the log, as above. PlayerSession.ts holds 132 awaits, down from 133; World keeps 28 reachable from update.
  • Not done, with the reason and what each blocks:
    1. Every owner slot from inventory-slot entries (D4e's 1). Replication would have to know which slots an ItemStackResponse already answered, or each request's slots go out twice. The response is built in the handler from the request, so this needs the response to be an occurrence that names its slots. Until then the whole-inventory resends in command/host/, live.ts and item/interaction.ts call sendInventory from simulation code. It blocks moving replication off the simulation's thread.
    2. Trade prices at the flush (D4e). The price depends on the trading player's cures and hero effect, which are not in the log. A merchant entry says the offers changed, and the screen's TradeSource prices them when they go out. It blocks a replica that answers a trade screen alone.
    3. Tracking over every entity the replica holds (D4c). A tracking pass decides what each viewer sees from where the viewers stand, which moves without an entry for the entities; it runs on its interval over the replica, not the simulation. Making it follow the log needs a way to find the entities near a viewer that moved without walking all of them. It blocks nothing in slice 7, since the pass reads only the replica.
    4. The workstation input slots (OpenBlockContainer.inputs, the D2 close). The digest and the save hold no workstation slots, so the snapshot target does not need them, and logging them would put a window that is session state into the log. It needs the decision the D2 close named: whether an open workstation is player state, with a home on the store player. It blocks undoing a stack request that moved items into a workstation and then threw.
    5. entityMoved and timeChanged, gameRuleChanged, as above.
  • Changeset change-log-snapshot.md: minor for @jsprismarine/prismarine (public members removed or changed: WorldChangeSink.statusEffectChanged, UnitJournal, the async return of four World methods) and patch for @jsprismarine/engine-systems (new observe and restore). Both are below 1.0.0.
  • Checks at D4f's head, on program head 847fe2cf32: pnpm run build, pnpm run typecheck, pnpm run lint:custom and pnpm run lint pass; lint's one warning is the unused Tag in packages/leveldb/src/db/VersionEdit.ts. The Prismarine, parity, client, block-behaviors and engine-systems suites with JSP_CONTENT_VANILLA=1 passed 543 of 545 files (5421 tests) at load average 13 to 30; the two failures were port binds under load, Cli.test.ts (UDP port 19280 in use) and a timeout in modules/network.test.ts, and both files pass alone. Client e2e spawn, rejoin, movement-fanout, block-placement, mining-drops, lever-lamp, lit-furnace, attack-entity, item-drop-pickup, villager-trade, recipe-book, eating and death-respawn pass together, and dimensions passes alone.

Phase E: the synchronous tick ​

PackageSliceDepends onWork
E18D3, B4Saves at a tick boundary, under ADR 0018
E29D4Dimension travel as messages, and worlds built outside ticks
E39D4Chunk tickets and waiting states for the dragon fight
E49E2, E3Tick functions as inputs, and the test that a tick completes without yielding

E1, E2 and E3 run side by side.

E1 split ​

E1 runs as five parts. E1a goes first, and E1b needs it. E1c and E1e can run side by side once E1b has landed. E1d edits the provider's chunk write, as E1c does, so it follows E1c. Each part leaves the suite green, and the replay digests do not move.

PartWorkMain filesNot mechanicalGate
E1a the commit pathDatabase.write takes a per-call sync. Database.commit applies a batch to the memtable at once and appends and syncs its log record on libuv's thread pool, framed as fragment headers over slices of the encoded batch, so the framing copies nothing. Commits append in sequence order, and write, flush and close wait for or refuse an append in flightpackages/leveldb: Database.ts, format/LogFormat.ts, db/Storage.tsWhat a read sees while a commit is in flight, and what a failed append leaves: the memtable holds a batch the log does not, so the database refuses every later writeA torn commit applies none of its keys after a reopen; one commit appends one record; the bytes a commit copies are its encoded batch and nothing else
E1b the level's passOne pass for a level and every dimension it holds. The cut runs in Level.update after the worlds, on the autosave tick: it builds every player record, persists the data entities, takes each chunk's entity membership and actor records, and plans each changed chunk's sub chunks. A sub chunk is encoded over the following ticks, a bounded count each tick, or first when a write is about to reach it, with the chunk's Data3D. The pass commits one batch through E1a outside the tick, marks each chunk saved as of the cut, and rewrites level.dat once, by atomic replace. A player record or chunk written outside the pass waits for the pending commitworld/LevelSave.ts (new), Level.ts, World.ts, chunk/Chunk.ts, providers/leveldb/LevelDB.ts, providers/leveldb/LevelDat.ts, PlayerPersistence.tsMarking a chunk saved against the cut's revisions rather than its current ones; a write that bypasses Chunk (getSubChunk(...).setBlock) is caught by a revision check at encode time until E1e closes itOne batch per level per pass; a write after the cut is not in that commit and forces at most one sub chunk encode; a crash between cut and commit leaves the previous save; an idle pass writes nothing; the saved state equals the cut tick's
E1c writes outside a pass join the next commit (landed)A player's record on disconnect and on a dimension change, and a chunk written on unload or after loading dropped a stackless item, are held in the level's next batch instead of written. A read of a held key answers from the batch. An unloading chunk leaves memory once its bytes are held. The level record is written by a pass that cuts only the level's state, which carries the held writesworld/LevelSave.ts, Level.ts, PlayerPersistence.ts, World.ts (unloadChunk), providers/leveldb/LevelDB.ts, providers/leveldb/OverlayBatch.ts (new), chunk/Chunk.tsReads that must see held writes: readChunk, readPlayerData, the digest and actor reads of a later chunk write; and when a held batch commits if no autosave comes, at shutdownA disconnect after the cut lands in the commit after it; a crash before it loses the record and the world state together; a reload of an unloaded chunk reads its held bytes
E1d fresh chunks outside the cutA chunk generated since the last pass and untouched by gameplay is written on its own, as terrain and its generation spawns as pending entity records, with no actor records or digest. One that gameplay touches before it is written moves into the cut wholeproviders/leveldb/LevelDB.ts, chunk/Chunk.ts, World.ts (createGeneratedEntities)The generation spawns become live mobs when the chunk enters the world, so the fresh write needs a copy taken before that, and the next cut must delete the pending record when it writes the mobs. A pending record carries type and position only; a spawn's beam target, bottom plate and invulnerability need a format decision from the owner before this startsThe first pass after a start region holds what gameplay changed, not the 441 fresh chunks; a crash after a fresh write and before the cut reloads the generated mobs once
E1e read-only chunk gettersgetSubChunk, getOrCreateSubChunk and getBiomes stop handing out mutable state to code that runs after generation and loading. Generators and the provider get their own entry pointschunk/Chunk.ts, chunk/SubChunk.ts, chunk/ChunkBiomes.ts, the generators under generators/Which callers are generation and which are runtime; the benchmarks and tests that write through getSubChunkE1b's revision check has no reachable failure, and a type test shows a runtime caller cannot write through the getters
E1 ​

E1a, E1b, E1c and E1e landed, each in small commits. E1d did not start, and slice 8 is not whole until it lands or the ADR is amended to leave it out. It follows the merge of the program branch as #3060, which states the format decision it waits for: the pending entity record is this project's own key, so the game does not read it, and growing it needs a reader for the old shape.

  • E1a. Database.write takes a per-call sync. Database.commit applies a batch to the memtable at once, so a read sees it, and appends its record with fs.writev and syncs it with fs.fsync on libuv's thread pool. A record is framed as 7-byte fragment headers and block padding over views of the encoded batch (frameLogRecord), so the values are copied once, into the encoding; LogWriter frames through the same function and its output is byte for byte what it was. Commits append in the order they were made (AppendFile.appendLater), write refuses while one is appending (Database.appending says so), and the memtable flush and close wait for it. A failed append leaves the database refusing every later write, since its memtable holds a batch its log may not. replaceFileAtomically is exported for level.dat.

  • E1b. LevelSave owns a level's writes to its save. On the autosave tick, at the end of Level.update, its cut calls World.cutSave for every ready world: each viewer's record, dataEntities().persist(), the entity membership and actor records of every chunk that changed or holds entities, the block entities, and the deletions. It holds back each chunk's changed sub chunks and Data3D (LevelDBChunkCut), and plans the level record and level.dat from Level.levelDataNow. Each later tick encodes SAVE_ENCODE_BUDGET sub chunks. A write that is about to reach a held sub chunk calls ChunkSaveCutHook.beforeSubChunkWrite, which encodes Data3D and that one sub chunk first; a biome replacement encodes Data3D. After the last encode the pass commits one batch through Database.commit with sync, outside the tick, marks each chunk saved as the cut saw it (Chunk.markSavedAs), and then writes level.dat once, by atomic replace. A save that is asked for (Level.save, World.save, a shutdown, a replay checkpoint) cuts between ticks when its turn in the queue comes, and finishes an autosave still encoding first, because a shutdown stops the ticks.

  • At E1b's head, writes outside a pass waited rather than joined, until E1c: a chunk the unload pass writes, the stackless-item rewrite and saveLevelData queue behind the pass in LevelSave.enqueue, and PlayerPersistence.save waits for a commit that was pending when it was called. A record built before a cut lands before that cut's commit; one built after lands after it.

  • A batch that is not a pass's and is not held (since E1c, a provider used on its own and the legacy migration) is appended before write returns when no commit is appending (LevelDB.writeSmall). On the thread pool it lands a turn of the event loop later, and the replay harness's settle check, which cannot see thread-pool I/O, let the next tick run first: SessionReplay.test.ts saw two chunk leaves a tick late.

  • Proxy tests, counts and bytes only. LevelSave.test.ts: a pass over four chunks in each of two dimensions commits one batch holding both dimensions' keys, and nothing reaches the database during the ticks that encode it; a write after the cut forces one sub chunk encode, one in total, and the commit holds the block as it was at the cut; chunks written on every tick after the cut are saved as they were at the cut; a cow removed after the cut is in that commit and gone from the next; a copy of the folder taken between cut and commit reopens to the previous pass; a second pass with nothing changed writes no batch, and one after 20 idle ticks writes only jsprismarine:world_data. LevelDBSavePass.test.ts covers the provider alone, a commit cut in half included. Database.test.ts and LogFormat.test.ts cover a torn commit, the order of commits, and framing that copies no payload byte. Bytes per pass go through reportProgress from LevelSave.test.ts: 153 keys and 89,402 bytes for eight fresh chunks of solid stone.

  • No saved format changed. PlayerPersistenceFormat.test.ts, DiskFormat.test.ts, VanillaWorld.test.ts and the provider tests pass unchanged; the chunk keys and values a pass writes are those writeChunk wrote, and level.dat holds the same bytes, now written through a rename.

  • What runs at another point than before:

    1. The autosave's cut is at the end of the level's tick, after every world. It used to start at the beginning of each world's tick and run across later ticks' awaits.
    2. A pass spans as many ticks as its sub chunks need at SAVE_ENCODE_BUDGET a tick: 56 for 441 fresh Overworld chunks.
    3. level.dat is written once per pass, where it was written once per dimension.
    4. A shutdown writes every world of the level in one pass. World.disable saves through the level, World.stop stops without saving, and World.saveForReplay is gone.
  • Left for later, and what each blocks:

    1. E1d. Fresh chunks are still part of the cut, so the first autosave after a start region holds about 10 MB until its commit, the bound ADR 0018 accepts. Writing them outside the cut needs the generation spawns kept as pending entity records, and today's record holds a type and a position, not a spawn's beam target, bottom plate or invulnerability. Extending it is a format change, so it waits for the owner's decision.

    E1b listed E1c and E1e here, and an autosave skipped while any other write to the save was queued. E1c and E1e close all three; see below.

  • Checks at E1b's head: pnpm run build, pnpm run typecheck, pnpm run lint:custom and pnpm run lint pass; lint's one warning is the unused Tag in packages/leveldb/src/db/VersionEdit.ts. JSP_CONTENT_VANILLA=1 pnpm exec vitest run --maxWorkers=4 packages/leveldb packages/prismarine packages/client packages/parity passed 539 files (5,259 tests) at load average 16 to 17. Client e2e spawn, rejoin, block-placement, death-respawn and item-drop-pickup pass together, and dimensions passes alone.

  • E1c. LevelSave turns on LevelDB.holdOutsideWrites. From then on writeChunk, writePlayerData and writeWorldData put their keys into an OverlayBatch, which keeps one write per key, the latest, in the order the keys were last written. beginSavePass takes the held writes as the pass's preamble and commits them ahead of the pass's own keys in one Database.commit; a write held after that call waits for the pass after it, and an abandoned pass hands its preamble back. The unload pass and the stackless-item rewrite write their chunk at once rather than queueing, PlayerPersistence no longer waits for a pending commit when the provider stores the record (the fallback JSON still does), and LevelSave.enqueue and Level.enqueueSave are gone. When heldWriteBytes passes HELD_WRITE_LIMIT on a tick with no pass running, the tick commits the held writes in a pass that cuts nothing. Level.saveLevelData became a pass that cuts only the level's state, so it carries the held writes and writes level.dat after its commit. LevelDB.disable commits whatever is still held, and logs a commit that fails before it closes.

  • How a held write is read: every provider read goes through LevelDB.read, which looks at the held writes, then at each pass's batch the database has not applied yet, newest first, then at the database. An overlay in the provider, not the memtable. The memtable flushes to a table on its own schedule, when it fills or at close, which would make a held write durable before the pass that should carry it, and a held write in the memtable would also reach every other reader of the database. The pass's own batch is in the overlay because a write after a cut decides what to delete from what is stored, and the pass lands first. entriesNow gives listChunks and captureReplaySnapshot the same view, byte for byte the database's when nothing is pending.

  • The limit, 4 MiB, is the database's default write buffer: about 190 changed chunks at the measured 22,327-byte mean, or nine chunk borders a player crosses at the default view distance. An early commit then fills at most one memtable, and with the 10 MB ADR 0018 lets a pass hold, a pass cut just before the limit holds about 14 MB. The tick checks once, so the held bytes can pass the limit by one tick's writes, at most CHUNK_UNLOAD_BATCH_SIZE chunks.

  • The autosave skip: the autosave is still skipped while another pass runs, for the reason LevelSave.tick gives, an ever-growing backlog of passes over the same chunks. Writes outside a pass no longer take the queue, so a stream of unloads cannot delay it.

  • What was not mechanical:

    1. A chunk written outside a pass while an earlier pass that cut it has not committed. Chunk.getPersistenceChanges compares with the last save, not with that pass, so a section the pass puts and gameplay empties afterward is on neither list. The cut first has the earlier cut encode what it still holds (Chunk.finishSaveCut, ChunkSaveCutHook.finish), since it takes the chunk's hook, and then deletes each section the earlier cut put that the chunk no longer holds (LevelDB.unsettled). Chunk.markSavedAs ignores a state older than the one it holds, so the earlier pass's commit does not mark dirty a chunk the held write saved, which would have kept it from leaving.
    2. The legacy migration writes at once even while writes are held, because the level deletes level.json once it returns.
    3. Sixteen test files whose doubles of BaseProvider go through fakeLevel build them with stubOf(BaseProvider, ...), since LevelSave now calls the provider when it is built and on each tick.
  • Proxy tests, counts and bytes only. LevelSave.test.ts: a disconnect after the cut is not in that commit, which is one batch, and is in the next, whose heldWrites is 1; a disconnect and rejoin with no pass between reads the record back and writes no batch; a chunk unloaded and loaded again with no pass between has its gold block and writes no batch; three unloads and two disconnects between passes land in one batch holding the three chunks and both records, heldWrites 8 (a section and Data3D per chunk, a record per player); eight records under the limit commit nothing on a tick, and records past it commit one batch of their own on the next tick, which the following pass does not repeat; a copy of the folder taken after an unload and a disconnect reopens to the previous pass, without the record. reportProgress gives 361 records of about 11.6 KB, 4,200,220 bytes, for the early commit. LevelDBSavePass.test.ts: a held write of a chunk a pass still holds finishes that cut (two forced encodes) and deletes the section the pass put; an abandoned pass hands its held write to the next; closing commits what is held as one batch. OverlayBatch.test.ts and ChunkSaveCut.test.ts cover the batch and the two Chunk changes. The E1b figure, 153 keys and 89,402 bytes, did not move.

  • No saved format changed. PlayerPersistenceFormat.test.ts, DiskFormat.test.ts and the replay digests in SessionReplay.test.ts pass unchanged. VanillaWorld.test.ts is unchanged and skips without its fixture, which is not on this machine.

  • What E1c leaves:

    1. The fallback JSON records of a provider without player storage are not part of the commit: they are written after it, as before.
    2. level.dat is still written after the commit, not in it, as ADR 0018 says.
    3. Database.commit refuses a write once when a background flush failed, and then accepts writes again. A pass refused that way hands its held writes back, but its own chunks rely on staying dirty, as in E1b.
  • Checks at E1c's head: pnpm run build, pnpm run typecheck, pnpm run lint:custom and pnpm run lint pass; lint's one warning is the unused Tag in packages/leveldb/src/db/VersionEdit.ts. JSP_CONTENT_VANILLA=1 pnpm exec vitest run --maxWorkers=4 packages/leveldb packages/prismarine packages/client packages/parity passed 540 files (5,276 tests) at load average 20 to 26. Client e2e rejoin, spawn, death-respawn and dimensions pass, one at a time.

  • Found on the way: the LevelDB level record drops running raids (#3042).

  • E1e. getSubChunk, getOrCreateSubChunk, getSubChunks, getSubChunkEntries, getPersistenceChanges and networkSerializeBlobs hand out ReadonlySubChunk, whose getStorages returns ReadonlyBlockStorage, and getBiomes returns ReadonlyChunkBiomes. Each is a Pick of its class's readers, so the compiler refuses a write through one, and nothing is copied on read. The writable paths left are the chunk's own: setBlockRuntimeId, setBlock, restoreBlockRuntimeId and fillColumn for gameplay and the flat and debug generators; the new setBiome and fillBiomeColumn for a biome write after generation, which only tests and plugins make today; setBiomes, which the generators and the provider call to hand over a column they built; and the constructor, to which the provider, networkDeserialize and the data-driven generator hand the sub chunks they decoded or built, before the chunk is in a world. setBiomes now marks the chunk changed, and getOrCreateSubChunk returns the sub chunk already at its index, where it used to replace it with an empty one.

  • The revision check and bypassedWrites stay. A plain JavaScript plugin can still reach a sub chunk's own setBlock, and a builder could keep a reference to what it handed over, so the check is kept as one comparison per encode. LevelDBSavePass.test.ts drives each writable path after a cut: a block write forces one sub chunk encode and the commit holds the block as it was at the cut, a biome write forces none and the commit holds the biome as it was, and a write through the view, typed away with @ts-expect-error, counts one bypass and logs it. LevelSave.test.ts writes through World.setBlockRuntimeId on every tick of a pass and counts no bypass. ChunkReadonlyViews.test.ts checks at compile time that no getter's result has a mutator.

  • E1e changed no saved format and no replay digest: DiskFormat.test.ts, VanillaWorld.test.ts, PlayerPersistenceFormat.test.ts and SessionReplay.test.ts pass unchanged. A Changeset (read-only-chunk-getters) records the narrowed types as a breaking change to @jsprismarine/prismarine and names the methods to call instead.

  • Checks at E1e's head: pnpm run build, pnpm run typecheck, pnpm run lint:custom and pnpm run lint pass, with the same Tag warning. JSP_CONTENT_VANILLA=1 pnpm exec vitest run --maxWorkers=4 packages/prismarine packages/chunkgen packages/worldgen packages/lighting packages/client packages/parity passed 564 files (5,718 tests). Client e2e spawn, block-placement and rejoin pass, one at a time.

E2 ​

E2 is too large for one pass, so it splits in two. E2a goes first.

PartWorkMain files
E2a portal travel as messagesA trip through a portal leaves its world's tick as a message. The destination world is built and the arrival chunks are loaded outside every tick, and the destination admits the arrival in its input step once they are loaded. The source side leaves its world in the same input step, so a trip is one leave entry in the source world and one enter entry in the destination. Removes the portal travel entry of IN_TICK_CHUNK_LOADSworld/portal/DimensionTravel.ts, entity/data/storeActors.ts, world/Level.ts, world/TickContext.ts, Server.ts
E2b the other world changes/tp and changeWorld into another dimension, the queued change that completeDimensionChange starts, the credits, and SetDefaultGameType as messages, which removes their step.defer callscommand/host/entities.ts, command/jsprismarineCommands.ts, network/PlayerSession.ts, the handlers that defer a world change

E2a, and the credits from E2b, left these notes.

  • How a trip runs. In a source world's tick, StoreActor.update finds the contact ready and calls DimensionTravel.depart, which plans the trip from the source world alone (the route, the scaled target, the traveler's place in the portal, its size and facing), files it and returns. It starts the destination's preparation through outsideTick (world/TickContext.ts): Level.prepareWorld builds the world if nobody has entered it, then the chunks the arrival reads are loaded, and those chunks enter at an input step like any other. Nothing in the destination changes in that tick, and the traveler stays in its world. Server's input step runs DimensionTravel.admit after the chunk completions and the respawn answers. A trip lands once its world is ready (Level.readyWorld) and either a portal stands within the search radius in loaded chunks or every chunk of the creation area is loaded. The arrival is then resolved from loaded chunks only, a portal or the End platform is written, and transfer takes the traveler out of the source store and into the destination's. Both writes land in the tick the input step opened, one membership leave in the source world and one enter in the destination. A trip that is not ready stays filed, and a trip whose traveler left its world first is dropped.
  • Readiness is decided in the input step from loaded chunks, so a replay that admits the same chunk completions at the same ticks lands each trip at the same tick. Preparation runs while ticks run and is never awaited by one.
  • Timing: a trip lands at the input step of a later tick, not inside the update that started it. The traveler spends the rest of that tick in the source world, and the data-driven half of its tick runs there once more. No replay digest moved, because SessionReplay.test.ts has no portal trip, so nothing was re-baselined.
  • While a trip is filed, DimensionTravel.touch skips the traveler, so standing in the portal starts no second trip.
  • The credits. PlayerSession.completeCredits returns a boolean and files the change with DimensionTravel.move, which the same input step share carries out before the trips: changeWorld, then the respawn answer. ShowCreditsHandler no longer defers. The change runs one input step later than before; the client sees the same packets in the same order (client e2e ender-dragon).
  • Removed: DimensionTravel.resolve, and the IN_TICK_CHUNK_LOADS entry "portal travel loading the arrival chunks". Three entries are left: the respawn search, the dragon fight (E3) and World.setBlockRuntimeId.
  • step.defer calls, as noServerAccess.test.ts reports them: 20 in 12 handler modules before, 19 in 11 after.
  • Proxy tests, in DimensionTravel.test.ts under "DimensionTravel as messages": a mob's trip appends no membership entry in its source tick and no entry at all in the destination, then exactly one leave in the source and one enter in the destination at the input step; the same for a creative player driven through World.update with the completion queue active, counted over every input step until it lands; and a trip into an End nobody entered asks for the build exactly once, from outside the tick (insideTick() is false at the call), and the traveler is still in its world when the tick ends.
  • Left for E2b: /tp and changeWorld into another dimension, which await worldFor and changeWorld inside a command; the queued change completeDimensionChange starts, and its step.defer, which needs the advancement trigger kept after the sends; releaseChangeHeldOverDeath; SetDefaultGameType.
  • Left for E4: transfer still awaits inside the input step: the playerChangeDimension event, startCredits' send and changeWorld, which saves the player record and awaits its sends. A trip out of the End awaits the respawn search for a player and getSpawnPosition for anything else, also in the input step. touch still awaits entityPortalEnter. outsideTick is an AsyncLocalStorage.exit; once the tick is synchronous and the store is a flag, it is a call that clears the flag.
  • Risk for chunk tickets (E3): the chunks a trip loads hold no ticket. The unload pass can take them from a destination that has players before the trip lands, and the trip then loads them again.
  • Checks at this head, on program head 9c09572f42: pnpm run typecheck, pnpm run lint:custom and pnpm run lint pass. The Prismarine suite with JSP_CONTENT_VANILLA=1 passes, 442 files and 4493 tests, with 2 files and 10 tests skipped; the run before the last fix failed only blockContainer.test.ts, whose travel double had no isDeparting. The parity and client suites passed 68 of 69 files: SessionReplay.test.ts failed under the suite at load average 20 to 25 and passes alone with no baseline touched. Client e2e dimensions alone, then spawn, rejoin, death-respawn, spawnpoint-respawn, ender-dragon, item-drop-pickup and teleport pass. NetherNet tests needed pnpm --filter @jsprismarine/native-network run build:native in this worktree first.

E2b left these notes.

  • A move. DimensionTravel.moveTo(session, target, then) files a player's change of world, and move is the same for a world already built. A target (MoveTarget) answers the arrival once it can land without waiting and prepares what it waits for outside every tick: stemTarget builds a stem's world through Level.prepareWorld, spawnTarget searches a world's spawn when it has none, and arrivalTarget waits for nothing. The input step's admit carries out each ready move through changeWorld, then its then, before the portal trips. A move that is not ready stays filed, and every later move of the same player waits behind it, so a player's moves land in the order they were filed. A player who disconnected is not moved.
  • A cross-dimension /tp resolves the stem with WorldManager.stemFor and files a move to it, so the command returns at once and nothing waits on the destination. A dimension the level has no stem for is refused at once with "This level has no ... dimension". worldFor in command/host/support.ts is gone. WorldManager.startWorld, which a command that writes into an unbuilt dimension calls, builds it outside every tick through prepareWorld.
  • PlayerSession.setWorld, which the changeWorld command calls, files a move to the world's spawn and returns nothing; the command now answers "Moving ... to ...".
  • completeDimensionChange keeps its order: the teleport, the attributes, the inventory, SetSpawnPosition and then the changed dimension advancement hook, exactly as before, so no packet moves. Only its end changed: the change held behind the pending one is filed as a move rather than awaited. The change stops being pending before the method's first wait, so ServerboundLoadingScreenHandler calls it in the handler and hands the rest to step.output. The hook now runs in that output, outside the handler's unit, as D2c's and D2d's notes describe for other writes after a wait. The check is protocol.dimension-change and client e2e dimensions, which pass.
  • releaseChangeHeldOverDeath files the held change as a move and returns nothing; respawn calls it without waiting.
  • WorldMessages (world/WorldMessages.ts, Server.getWorldMessages, HandlerServices.messages) is how an input in one world changes another. A message names its world and is applied in the input step, after the moves and trips, as a unit of that world's change log. SetDefaultGameType writes the game mode of every player in the sender's world in its handler, with the sends in step.output, and sends each player in another world a message that sets the mode and then tells that client the default. Each client still hears its new mode and then the default; across clients the order is now per client rather than every mode first.
  • Timing: each of these changes lands at an input step after the one that asked for it, or later in the same step when the step's travel share has not run yet, as for a command a player types. A console command runs between ticks and lands at the next step. No replay digest moved, and nothing was re-baselined.
  • step.defer calls: 19 in 11 handler modules before E2b, 17 in 9 after (ServerboundLoadingScreen and SetDefaultGameType no longer defer). Left: PlayerAction (6) and InventoryTransaction (1), which break and hit blocks through World.breakBlock and hitBlock and ask for a respawn; Respawn (1), the respawn search, which is E3's; ResourcePackClientResponse (3) and ResourcePackChunkRequest (1), the kick and the join burst; CommandRequest, SettingsCommand and Text (1 each), a command and a chat line; and inputParts (2), which only runs what the parts it holds defer.
  • Proxy tests, in DimensionTravel.test.ts under "DimensionTravel as messages": a move into a Nether nobody entered, filed inside a tick, asks for the build once from outside the tick, appends no membership entry in the tick that filed it, and lands as one leave in the Overworld and one enter in the Nether; a player's second move waits behind a first that is not ready, and both land in order; a message for the Nether appends nothing in either world until the input step, and then entries in the Nether only. The unit tests for /tp, setWorld, the held changes, the loading screen and SetDefaultGameType assert that nothing changes before the step admits.
  • Left for E4, updated: admit still awaits changeWorld for each move and trip, which saves the player record and awaits its sends, and transfer awaits the playerChangeDimension event and startCredits' send. A trip out of the End awaits the respawn search for a player and getSpawnPosition for anything else. touch awaits entityPortalEnter. WorldMessages.admit awaits each message's sends, and completeDimensionChange's output awaits overworldSpawnOf, which reads the level's spawn and builds the Overworld only before it has one. spawnTarget searches a spawn outside the tick, and that search writes the world's spawn between ticks. outsideTick becomes a call that clears the tick flag once the tick is synchronous. WorldManager.worldFor still awaits a build and has no production caller left.
  • Checks at this head, on 1559b5ec5f: pnpm run typecheck, pnpm run lint:custom and pnpm run lint pass. The Prismarine suite with JSP_CONTENT_VANILLA=1 passes, 442 files and 4496 tests, with 2 files and 10 tests skipped. The parity and client suites pass, 69 files and 399 tests, SessionReplay.test.ts and protocol.dimension-change included. Client e2e dimensions alone, then spawn, rejoin, death-respawn, spawnpoint-respawn, ender-dragon, teleport and chat pass.
E3 ​

E3 is too large for one pass, so it runs as three parts, in this order. Each part removes its own IN_TICK_CHUNK_LOADS entry.

PartWorkRemoves
E3aChunk tickets on World: a ticket starts the loads it lacks outside the tick, so each chunk enters at an input step, and keeps its chunks from unloading until released. The dragon fight waits for its arena, for the chunks its death consequences write and for each pillar it resetsThe dragon fight entry
E3bThe respawn search reads loaded chunks only. An attempt that lacks a chunk takes a ticket and waits, and RespawnRequests tries it again at each input step. The death, the Respawn handler and the respawn action file requestsThe respawn search entry, and the two respawn step.defer calls
E3cWorld.setBlockRuntimeId, setBlockByName and breakBlock write into loaded chunks only, and breakBlock writes before it first waitsThe World.setBlockRuntimeId entry, and the three block action step.defer calls

All three parts landed in one pass.

  • world/ChunkTickets.ts holds a world's tickets, and World.chunkTickets() returns them. request(owner, chunks) starts a load for each missing chunk and returns whether all of them are loaded. A second request from the same owner replaces its ticket. The load runs under outsideTick (world/TickContext.ts), so its chunk waits for an input step even while the tick that asked is open. A ticketed chunk does not unload: World.chunkRetained asks the player areas and the tickets.
  • The dragon fight takes three tickets. dragon-fight:arena covers the four chunks around (0, 0) before the scan or a saved respawn and is released once they are in. dragon-fight:death covers those four and the next gateway's chunks before a dead dragon's consequences, and the fight does nothing else while it waits. dragon-fight:pillar is taken when a pillar's beam starts. The reset at the pillar's fortieth tick waits for it, and the respawn clock waits too.
  • The respawn search runs one synchronous body over loaded chunks. attempt takes a ticket in the player's name for each chunk it lacks, and keeps every chunk an earlier attempt asked for in that ticket until the search answers or release drops it, so a chunk the search already read cannot unload between attempts. It starts a missing world or world spawn outside the tick, and says it is waiting. It reads the world spawn only once the player's own point is settled. RespawnRequests.admit tries each request at every input step, after the chunk admissions. A waiting request holds back the later requests of the same player and no one else's. find loads what attempt lacks and waits, for DimensionTravel.leaveEnd only, and PlayerSession.getRespawnPoint keeps it for that caller.
  • World.breakBlock, setBlockRuntimeId and setBlockByName are synchronous and loaded-only. World.loadedBlock reads a Block from a loaded chunk.
  • IN_TICK_CHUNK_LOADS went from four entries to two, and E2 owns both: portal travel's arrival chunks, and the respawn search portal travel out of the End awaits. The step.defer count went from 20 calls in 12 handler modules to 15 in 10. The respawn action, the Respawn handler and the three breaks no longer defer.
  • What a client sees changes only where a chunk was not loaded. The dragon's death consequences, a pillar reset, and a death screen or respawn whose chunks were missing now arrive at the first tick after the chunk does, where they used to arrive in the tick that loaded it. A break or a /setblock in a chunk that is not loaded now does nothing. Replay digests did not change.
  • Proxy tests, which assert counts and order. ChunkTickets.test.ts checks one load per missing chunk however often it is asked, and a hold until the last release. WorldPlayerChunkCounts.test.ts checks that a ticketed chunk survives an unload pass. DragonFight.test.ts has four. Three waiting ticks start one load, and the death runs in the tick whose input step admitted the chunk, with no admission in the middle of a tick. The four chunks of gateway 0 give the same per-tick fight state in either arrival order. A pillar's clock holds at 39 until its chunks arrive. RespawnSearch.test.ts and RespawnRequests.test.ts check one load per missing chunk, one build per missing world, and the per-player order.
  • Left for E4: the stall guard and TickStalledError in the ticker, and TickContext's AsyncLocalStorage. RespawnRequests.admit, DragonFight.tick and the explosions it awaits are still asynchronous, and so is the change of world inside a respawn's answer, which E2's travel as messages removes. BlockPhysics still awaits the now-synchronous setBlockByName.
E4 split ​

E4 runs as six parts, in this order. Each removes the awaits listed for it and leaves the suite green, and only the last one makes the tick synchronous. The worklist is every await reachable from the ticker's simulation phases. At the start, PlayerSessionAwaits.test.ts reports 28 awaits in 51 World methods reachable from update and 35 in 40 PlayerSession methods reachable from updatePlayer; it follows calls on this only, so the parts below also list the awaits it does not see. step.defer is called 12 times in 7 handler modules.

PartWorkAwaits it removes
E4a travel and messagesThe input step's travel and message shares stop waiting. changeWorld, startCredits and the detour queue their packets and hand the record save to work outside the tick. A trip out of the End asks the respawn search with attempt and waits at the input step, and a trip's arrival chunks hold a chunk ticket until it landsDimensionTravel.admit, admitMoves, transfer, touch (entityPortalEnter), leaveEnd; changeWorld's save and sends; playerChangeDimension; the credits send; WorldMessages.admit; RespawnRequests.admit; completeDimensionChange's overworldSpawnOf. Removes RespawnSearch.find and the last IN_TICK_CHUNK_LOADS entry
E4b the world's phasesBlock physics, the dragon fight and its explosions, the portal touches, the entity updates and the data-driven entities run without waitingWorld.tickWorld and what it reaches: runBlockUpdates, touchPortals, DragonFight.tick, StoreActor.update, LiveEntities.tick, BlockPhysics
E4c replicationThe flush phases queue their packets and do not wait for the transport, and so does each player's part of the tickWorld.replicateTick and the NetworkWorldReplicator flushes, PlayerSession.updatePlayer and what it reaches, flushOutboundPackets
E4d the input stepThe input queue applies each input in one call. A handler's outputs are handed off, and each step.defer whose reason is gone runs in the handlerInputQueue.admit, HandlerContexts.run, the chunk admissions, the player removal
E4e the tick event and functionsThe tick event calls its listeners without waiting. Tick functions run as an input of the world they run in, and a command in one that acts in another world is a message to that worldemitTick, CommandManager.tickFunctions
E4f the synchronous tickWorld.update and the ticker's simulation phases are synchronous. TickContext keeps a flag the ticker sets, outsideTick clears it, and the stall guard goes. A gating test runs a tick with the wall clock stubbed to throw and checks that it finishes in one call with nothing pendingThe awaits in ServerTicker.runTick
E4 ​

E4 landed in the six parts of its split, each its own commit or two, with one change to the split: PlayerSession.updatePlayer and what it reaches went with E4b, because the player's actor runs it inside the entity updates, and E4c kept the replication phases.

  • E4a. DimensionTravel.admit, admitMoves, transfer and touch, RespawnRequests.admit and WorldMessages.admit run in one call. changeWorld, startCredits, the detour and the respawn answers queue their packets and return a promise that only says when the transport framed them, and the record's save is handed off. entityPortalEnter and playerChangeDimension are posted and read as guards (ADR 0013), so a listener cancels before it returns. A trip out of the End asks the respawn search with attempt through PlayerSession.attemptRespawnPoint and stays filed until the search has its chunks; anything else waits for the Overworld spawn, searched outside the tick. RespawnSearch.find and the last IN_TICK_CHUNK_LOADS entry are gone. A trip's arrival chunks hold the chunk ticket travel:<n> from its preparation until it lands or is dropped. completeDimensionChange reads the Overworld spawn the level knows (knownOverworldSpawnOf), and searches outside the tick only for a level that has none.
  • E4b. Block physics, the dragon fight and its explosions (explode is synchronous), the boss bar, the portal touches, StoreActor.update, the status effects, the data-driven entities and the player's own part of the tick run to their end. World.dropFromBlock writes and returns. A first sight's spawnTo builds its batch before it returns, so the tracker still marks an entity only once its spawn is queued.
  • E4c. The replicator's flushes, EntityReplication.flush and flushDepartures, the tracking pass, the clock and the game rules return void and queue their packets; WorldChangeSink's tick phases are void. allSent moved to network/allSent.ts. sendContainerData queues every changed counter at once: it awaited each in turn, so its later packets left after the tick and would have counted toward the next one.
  • E4d. InputQueue.admit, the dispatcher's input handlers (HandlerContexts.runInput), the chunk admissions and the player removal run in one call, and InputStep.output is let go instead of waited for. InputStep.defer and the input parts that ordered deferred work are gone, as are EntityStore.unitAsync, World.runInput and applyInput. A block hit and the crack animation read loaded chunks. Commands run in the handler through CommandManager.runCommand, the chat line calls its listeners in the handler, a kick queues the goodbye and takes the player off as it is called, and the player's leave queues its sends and hands off its save. PlayerPersistence orders the writes of one key and makes a read wait for the pending write, so a player who leaves and joins again reads the record they left with. The join burst runs in its handler once the world's spawn is known, and otherwise is a message to that world after a search outside the tick.
  • E4e. Tick functions run at the end of the input step, before the worlds update, as one unit of the default world (World.runInputNow). Java's tickChildren runs its functions before its levels; the ticker's comment that said "after the worlds, as Java runs it" was wrong. The tick event is posted, and EventEmitter.post reports an asynchronous listener's rejection to a handler.
  • E4f. World.update, Level.update, ServerTicker.advance and the loop's tick are synchronous. TickContext is a plain flag the ticker sets in runInTick and outsideTick clears; the wait trackers, which follow read-lane handlers and chunk deliveries across their awaits between ticks, moved to world/TickWaits.ts and keep their AsyncLocalStorage. Nothing a tick runs waits for a chunk, so the stall guard, TickStalledError, admitNow, admitParked, IN_TICK_CHUNK_LOADS and admittedInTick are gone. The drain of chunk preparations, which reads a wall-clock budget, moved after the simulation phases. Server takes a tickClock for the loop's schedule and timings.
  • The slice 9 target gates in packages/client/src/SynchronousTick.test.ts: a real server and a loopback client, ticking disabled, 20 ticks with a chat input, falling sand and flowing water, performance.now and Date.now throwing when read inside a tick. Each tick returns undefined from Server.advanceTick with the change log sealed for that tick, and the world's digest under that tick's header is the same after the event loop drained. An await at the top of World.update fails it on the sealed tick, and a Date.now() in World.tickWorld fails it on the reads.
  • Counts. PlayerSessionAwaits.test.ts reported 28 awaits in 51 World methods reachable from update and 35 in 40 PlayerSession methods reachable from updatePlayer at the start, and 6 in 51 and 17 in 41 at the end; PlayerSession.ts holds 66, down from 128. It follows every call on this, awaited or not, so what it still counts is work the tick starts and does not wait for: the unload pass and its saves (unloadChunks, unloadChunk, enqueueSave) in World, and chunk delivery (flush, flushQueuedChunks, announceSpawnIfReady, answerRespawnIfReady) in PlayerSession. The gating test is what shows the tick awaits none of it. step.defer: 12 calls in 7 handler modules at the start, 0 at the end.
  • What arrives later or in another place than before:
    1. A trip out of the End whose respawn chunks are not loaded lands at the input step after they are; the step used to wait for them inline.
    2. A join into a world that has not found its spawn sends its burst at the input step after the search, and a dimension change on a level with no spawn sends its SetSpawnPosition after the search. Neither happens on a level a player has joined.
    3. Tick functions run before the worlds update in the same tick, where they ran after the seal. Their writes are in that tick's change log and go out with its replication.
    4. A plugin's asynchronous tick listener, and the part of a chat listener after its first wait, run after the tick. A cancel of entityPortalEnter or playerChangeDimension after an await is too late.
    5. A command's CommandOutput is queued once the command's synchronous part has run, so feedback a command sends after a wait, such as /op or /ban, now follows it.
    6. A trip's arrival chunks stay loaded until it lands, which closes E2's risk note.
  • Replay digests did not move, and nothing was re-baselined: SessionReplay.test.ts passes its three tests with the baselines as they were.
  • Left for later:
    1. A command in a tick function that acts in another world writes it directly, in the same input step, when no world is in the middle of its update, but as no unit of that world's change log and not through WorldMessages. A host verb answers synchronously (setblock reports whether the block changed), and Brigadier's dispatch cannot wait for a message's answer. Making it a message needs a verb that can file its write and answer from the target world's plain data.
    2. The wait trackers still use AsyncLocalStorage, because the work they follow awaits between ticks; nothing inside the tick reads them.
    3. Chunk preparation and the chunk scheduler's slice still read the wall clock, outside the simulation phases; ADR 0009 allows the clock to decide only when a chunk is ready.
  • Checks at E4's head, on program head b18cb2a1cb: pnpm run build, pnpm run typecheck, pnpm run lint:custom and pnpm run lint pass; lint's one warning is the unused Tag in packages/leveldb/src/db/VersionEdit.ts. The Prismarine, parity, client, commands, block-behaviors and engine-systems suites with JSP_CONTENT_VANILLA=1 passed 556 of 558 files at load average 14 to 15; SessionReplay.test.ts and HttpSignaling.loopback.test.ts failed under the suite, and both pass alone after the tick clock fix. Client e2e dimensions alone, then spawn, rejoin, chat, movement, block-placement, mining-drops, lever-lamp, attack-entity, death-respawn, spawnpoint-respawn, ender-dragon, teleport and villager-trade pass. The NetherNet tests needed the native library, copied from a worktree with the same native sources because the shared CMake cache belonged to another checkout.

Phase F: cleanup ​

PackageSliceDepends onWork
F110E4One lifecycle event bus
F211F1Explicit exports for Prismarine, split by directory
F1 split ​

Written on 2026-11-12 at program head 8de27db6bc.

The server has two emitters today. No world, session or player has an emitter of its own.

  • Prismarine's EventEmitter in events/EventEmitter.ts. Server extends it, so server.on, once, removeListener, removeAllListeners, hasListeners, emit and post are its methods. It also holds evtThirdParty, a second untyped bus for events plugins define, which nothing raises or reads. Its emit calls every listener at once and waits for them with Promise.all. Its post calls them and returns. A listener that throws stops the listeners after it and throws into the code that raised the event.
  • The kernel's KernelEventBus in packages/kernel/src/events.ts, which each module reaches through its context as a bus scoped to the module. It is untyped in Prismarine, which builds a plain Kernel, and nothing in the server subscribes to it or raises an event on it.

The kernel bus survives, typed by a ServerEvents map that lists the 18 events EventTypes lists now, under the same names and with the same event classes. The kernel bus gains what the server's events need: post, which calls each listener in subscription order and returns, once, off and hasListeners. A listener that throws or rejects is reported to the kernel's logger, and the listeners after it still run, on post and on emit.

Each event keeps its emit site and its timing. A before-hook is posted inside its unit and its cancel is read when post returns: chat, entityDamage, entityPortalEnter, playerChangeDimension, playerMove, playerSetGamemode, playerToggleFlight and playerToggleSprint. The after-events are posted: tick, entityDeath, playerSpawn and playerToggleOperator. The three RakNet events stay awaited with emit, because they run outside the tick and raknetConnect reads a cancel made after a wait. playerConnect, playerDisconnect and playerDespawn keep their names in the map, though nothing raises them (#2972).

The order of the work:

  1. The kernel bus gains post, once, off, hasListeners and the reported failures, with the proxy tests.
  2. Server builds a Kernel<ServerEvents> and stops extending EventEmitter. on, once, removeListener, removeAllListeners and hasListeners stay on Server and subscribe on the kernel bus, and post and emit forward to it until the emit sites move.
  3. chat: ChatManager posts on its module's bus and then delivers to its recipients, one pass, instead of one listener per recipient. The join message goes through ChatManager.send.
  4. tick: emitTick leaves TickerServer, and the ticker posts on the bus it is given.
  5. entityDamage and entityDeath, then the player events, then the portal events, then the RakNet events, a group per commit, each raised on the bus.
  6. Server.post, Server.emit, EventEmitter.ts and evtThirdParty are deleted.

What a plugin's listener sees differently, and what a plugin author changes:

  1. A listener that cancels chat stops every player, including those who joined before the listener subscribed. Before, each player was its own listener, and a cancel stopped only the players who joined after the canceling listener. A cancel made after a wait is still too late.
  2. Every listener of an event that the server waits for with emit (raknetConnect, raknetDisconnect, raknetEncapsulatedPacket) waits for the listener before it. Before, they all started at once.
  3. A listener that throws or rejects no longer stops the listeners after it or the code that raised the event. The server logs the error. A before-hook that throws neither cancels nor changes the action.
  4. Subscribing the same function twice to one event still calls it once.
  5. removeAllListeners('chat') no longer takes chat away from the players, because they are no longer listeners.
  6. server.emit, server.post and server.evtThirdParty are gone, and Server is no longer an EventEmitter. A plugin that raised a server event, or used evtThirdParty, has to stop. server.on, once, removeListener, removeAllListeners and hasListeners take the same arguments as before.
F1 ​

F1 landed in the order of its split, with one change: the RakNet events moved in the commit that put Server on the kernel bus, because ServerNetworking hands the transports and the dispatcher the bus itself and nothing else raised them.

  • The kernel bus. post calls each listener in subscription order and returns how many it called; emit waits for each one before it calls the next. Both report a listener that throws or rejects to onListenerError, which a Kernel sends to its logger, and go on to the next listener. once, off, removeAll and hasListeners are new, a handle subscribes the same function to an event once, and a listener may return any value.
  • The emitters. Before: Prismarine's EventEmitter, which Server extended, with evtThirdParty beside it, and the kernel's bus, which nothing used. After: the kernel's bus, typed by ServerEvents in events/ServerEvents.ts. Server builds a Kernel<ServerEvents> first in its constructor, and its on, once, removeListener, removeAllListeners and hasListeners subscribe on it. EventEmitter.ts, PluginEventEmitter, EventTypes, Server.post and Server.emit are gone. The docs facts read the events table from ServerEvents, and it came out unchanged.
  • Each event and where it is raised now. chat: ChatManager.send posts it on the chat module's bus and then delivers to the joined players in one pass, so a player is no longer a listener; the join message goes through ChatManager.send instead of an emit of its own. tick: the ticker posts it on TickerServer.events, which replaced emitTick, at the point emitTick was called. entityDamage (a before-hook inside the blow) and entityDeath (after the commit, inside the blow, before the advancement triggers): live.ts. playerSetGamemode, playerToggleFlight, playerToggleSprint: PlayerSession. playerToggleOperator: PermissionManager. playerMove, playerSpawn: the handler services' events, which is the bus. entityPortalEnter, playerChangeDimension: DimensionTravel. raknetConnect, raknetEncapsulatedPacket: RakNetTransport, and raknetDisconnect: NetworkDispatcher, all three awaited with emit. None of them moved relative to the simulation: the posted events are posted where they were posted, and a before-hook's cancel is read when post returns, as before.
  • When a listener runs, compared with before: a chat player is reached after every chat listener, where before a player who joined before a listener was reached before it; the RakNet listeners run one after another; and a listener after one that throws now runs. The split lists what a plugin's listener sees differently and what a plugin author changes.
  • Proxy tests, all by order and count. events.test.ts in the kernel: post calls 2 listeners in subscription order and returns before the asynchronous one's wait; a throwing and a rejecting listener are reported, 2 reports, and the third listener still runs, on post and on emit; a cancel is seen by the listener after the canceler; once calls its listener for 1 event of 2; the same function subscribed twice is called once and post returns 1. kernel.test.ts: a failing listener writes 1 line to the kernel's logger and the next listener runs. ServerEvents.test.ts on a real Server: server.on and the kernel bus are one list, 3 listeners called in subscription order by the chat module's post, and after a throwing listener post still calls 3 and then, with the once gone, 2. ChatManager.test.ts: 2 listeners then 2 players in one pass; a cancel stops both players whenever they joined, and the listener after it sees the cancel; a cancel after a wait delivers 1 line; a throwing listener gives 1 report and 1 line. ticker.test.ts: in one tick, a throwing and a rejecting tick listener give 2 reports, the third listener runs, and the network poll and the tick count still advance.
  • Replay digests did not move: SessionReplay.test.ts passes with the baselines as they were. Client e2e dimensions alone, then spawn, chat, rejoin, attack-entity, death-respawn, block-placement and ender-dragon, pass.
  • Left for later:
    1. Chat is posted and then delivered, which is the order of a guard and a commit, but it is not a pipeline of ADR 0013: no guard list, no replaceable message, and no rejection record. The script host's chat before-event will need one.
    2. The console and the remote console still show chat as chat listeners, so a plugin that subscribed after them and cancels does not hide a line from the console.
    3. The three RakNet events are still awaited one listener at a time. They run outside the tick, and raknetConnect reads a cancel made after a wait, so they stay as they are until the transport has a guard of its own.
  • Checks at F1's head, on program head 8de27db6bc: pnpm run build, pnpm run typecheck, pnpm run lint:custom and pnpm run lint pass; lint's one warning is the unused Tag in packages/leveldb/src/db/VersionEdit.ts. The Prismarine, kernel, parity, client, commands and server suites with JSP_CONTENT_VANILLA=1 pass 541 files at load average 24 to 30. commandStartup.test.ts failed twice while another checkout's tests held its UDP port (#3000) and passed in the next full run. The built @jsprismarine/prismarine and @jsprismarine/kernel, loaded under the jsprismarine:dist condition, give Server its subscribe methods and no post or emit, and the bus reports a throwing listener and calls the next one.
F2 split ​

Written on 2026-11-12 at program head 9ecbe88282.

Prismarine exports "." and "./*", so every file under src/ is importable, and the barrel baseline holds 20 of its files, with 20 noBarrelFile and 77 noReExportAll findings. The root index.ts re-exports 13 directory barrels and Managers.ts, and events/Events.ts, network/index.ts and blockentity/index.ts gather symbols by importing and exporting them again, which Biome does not see. Outside the package, the server package imports 6 modules, the client's tests 10 and the parity suite about 70, and Prismarine's own benchmarks import it by name to pick up the built files under jsprismarine:dist.

The published package keeps these entry points, each a module that defines what it exports:

Entry pointWhy it is public
., which becomes Server.tsThe class an embedder builds. Server.ts gains a named Server export beside its default one, so import { Server } from '@jsprismarine/prismarine' still works
./version@jsprismarine/server prints the version with serverVersion
./config/ConfigAn embedder builds a Config, as the extending guide shows
./console/RemoteConsole, ./console/RemoteLogTransport@jsprismarine/server attaches its console with them
./content/settings, ./content/packStack@jsprismarine/server reads the content settings and resolves the pack stack before it builds a Server
./events/ServerEvents, ./events/Event and each event class's module under events/Listeners receive these classes, and ServerEvents types server.on
./chat/ChatChatEvent.getChat() returns it
./network/PlayerSessionThe player events and the Server getters hand out sessions
./network/InboundPacketTrace, ./network/OutboundPacketTrace, ./network/ReplayManifestThe replay API the package README documents
./modules/tokensThe kernel tokens are how a program reaches the server's services, which Server's own comments and the README name
./world/Level, ./world/WorldThe README documents Level.captureReplayInitialState and World.replayStateDigest, and events return worlds
./world/GeneratorFactoriesA program registers a generator kind on it
./world/generators/datadriven/DataDrivenGenerator, ./world/generators/datadriven/SharedTerrainTileStoreThe README's terrain cache and shared tile examples import them

Everything else becomes internal to the published package. The workspace keeps explicit entries, in exports but not in publishConfig.exports, for the internal modules its own consumers import today: the parity suite, the client's tests and Prismarine's benchmarks. Test helpers under testing/ get a source target only, since the build does not emit them. pnpm writes publishConfig.exports over exports when it packs, so none of these reach npm. The two null entries go, because nothing is left for them to block.

The barrels go, and each importer imports from the defining module: the root index.ts, Managers.ts, the directory index.ts files under block, blockentity, command, command/function, command/host, config, console, content, entity, inventory, item, network, utils, world, world/generators/datadriven and world/generators/factories, events/Events.ts, and the re-exports in world/Dimension.ts and world/generators/datadriven/javaWorldgenData.ts. world/generators/factories/index.ts keeps builtInGeneratorFactories, which it defines, in a module of its own. network/Handlers.ts stays: it is the handler registry's list, and nothing outside the package reads it once network/index.ts is gone.

The order, so each commit builds:

  1. "./*" is replaced by an explicit entry for every specifier the workspace imports today. The root still points at index.ts, and no import changes.
  2. One barrel per commit, deepest first: the generator factories, the data-driven generator, Dimension.ts and javaWorldgenData.ts, then world, entity, block, blockentity, item, inventory, content, config, console, command, network, utils, Managers.ts and events/Events.ts. Each drops its line from the root index.ts, moves its importers to the defining modules and lowers the baseline with node scripts/check-barrels.mts --update.
  3. The root: "." points at Server.ts, index.ts is deleted, publishConfig.exports lists the public entries, and the API reference reads Prismarine's entry points from publishConfig.exports.
  4. A test fails when scripts/barrel-baseline.json has an entry under packages/prismarine or when the exports map has a pattern, which makes the slice 11 target gate.

What a consumer changes:

  1. Import each symbol from the module that defines it, through one of the entry points above. import { Config, Server } from '@jsprismarine/prismarine' becomes import { Server } from '@jsprismarine/prismarine' and import { Config } from '@jsprismarine/prismarine/config/Config'.
  2. The root no longer exports Events, Handlers, BlockEntities, the managers or any other namespace. Event classes come from their own modules, such as @jsprismarine/prismarine/events/chat/ChatEvent.
  3. A path that is not an entry point above stops resolving from the published package. A program that needs one should open an issue, so the module can be made public on purpose.
F2 ​

F2 landed with the entry points of its split and one change to the order: the root went first, not last. Moving every importer of the root index.ts to the defining modules in one commit left the directory barrels with no importer but the root, so each later commit deleted one barrel and touched only that barrel's own importers. A codemod over the TypeScript syntax tree did the moves: for each named import of a barrel it followed the re-exports to the module that declares the symbol and wrote one import per module. Nothing outside the package changed behavior; only import paths moved.

  • The barrels. Before: 20 Prismarine files in scripts/barrel-baseline.json, with 20 noBarrelFile and 77 noReExportAll findings. After: none. The root index.ts, Managers.ts, the 16 directory index.ts files and events/Events.ts are deleted, world/Dimension.ts and javaWorldgenData.ts no longer re-export, and builtInGeneratorFactories moved to world/generators/factories/builtIn.ts. Most directory barrels had no importer except the root; utils, command/function, network and the world barrels had one to three each.
  • The root. "." is Server.ts, which gained a named Server export, so import { Server } from '@jsprismarine/prismarine' is unchanged and ./Server is no longer an entry. packages/server loads Config, RemoteConsole and RemoteLogTransport from their modules beside the root, and its declaration build maps the root to Server.d.ts. The handler check in noServerAccess.test.ts counts an import of the package root as an import of Server.
  • The exports maps. The published map has the 39 entry points of the split. The workspace map has those and 77 internal modules that the parity suite, the client's tests and the benchmarks import, 4 of them test helpers with a source target only. pnpm writes the published map over the workspace one when it packs. network/index became network/contentPayloads, the module the parity suite actually reads.
  • The API reference. packages/prismarine/typedoc.config.mjs reads the entry points from publishConfig.exports, so Typedoc documents the published modules and no others. The extending guide lists them through the prismarine.entryPoints.list docs fact, read from the same map.
  • The gate. noBarrel.test.ts fails when the baseline has a Prismarine entry, when either exports map has a pattern, when a workspace entry names a missing source module, or when a published entry is not a built workspace entry. With pnpm run lint:barrels, which fails on any barrel finding the baseline does not allow, it keeps slice 11's target.
  • The consumer check. The built @jsprismarine/prismarine and the 43 workspace packages it depends on, packed with pnpm pack and installed with npm in an empty ESM project, typecheck a file that imports all 39 entry points and a file that embeds the server as the extending guide does, under TypeScript 6 with --moduleResolution NodeNext and with Bundler, skipLibCheck off. An import of world/chunk/Chunk fails with TS2307 under both. At run time world/chunk/Chunk, world/positionKeys, index and Server throw ERR_PACKAGE_PATH_NOT_EXPORTED, and the root, config/Config and version load.
  • What a consumer changes is in the split, and the Changeset prismarine-explicit-exports.md (minor for Prismarine, patch for the server) lists where each former root export lives.
  • Left for later:
    1. Re-exports Biome does not count. Five type-only re-exports (BanManager.ts, World.ts, WorldManager.ts and DragonFight.ts from the config types and WorldPersistence.ts) and nine modules that import a symbol and export it again, such as inventory/WindowIds.ts, entity/Difficulty.ts and content/world/adapter.ts. None is a barrel, and each is one or a few symbols.
    2. network/Handlers.ts imports every handler and exports them again. It is the handler registry's list, nothing outside the package reads it, and replacing it with a list module of its own is a change to the registry.
    3. The 77 internal workspace entries. A parity test that reaches an internal module keeps an entry for it; moving those tests onto public entry points, or making a module public on purpose, would shrink the list.
  • Checks at F2's head: pnpm run build, pnpm run typecheck, pnpm run lint:custom and pnpm run lint pass; lint's one warning is the unused Tag in packages/leveldb/src/db/VersionEdit.ts. With JSP_CONTENT_VANILLA=1, Prismarine's suite passes 446 files with 2 skipped, and the client, server, parity and commands suites pass 94 files, at load average 16 to 17. SessionReplay.test.ts passes with its baselines as they were, so the replay digests did not move. pnpm exec vitest run scripts tools passes 14 files. node scripts/check-workspace-conditions.mjs resolves every workspace export from source and from dist. Client e2e dimensions alone, then spawn, chat, block-placement and death-respawn, pass, and spawn passes against the built packages with pnpm run e2e:client:dist. pnpm --filter @jsprismarine/docs run docs:stage runs clean, and typedoc --emit none reports 0 errors.

The gate for each slice ​

  • Typecheck, lint and tests pass.
  • The parity families the slice touches pass.
  • Replay digests are unchanged, or are re-baselined in a separate commit that states the intended change. Until slice 9 this compares one replay with another under the guarantees listed for the slices already landed.
  • A client e2e scenario covers any client-visible change.
  • The proxy tests that gate at this slice pass.
  • A Changeset for each affected package follows the versioning policy.
  • The docs the slice invalidates are updated, such as entities, engine systems and the status in the server re-architecture plan.

Performance targets ​

Each target is a count or a structure asserted in a test. None is a duration.

TargetGates from slice
Every handler declares a lane, checked against the handler registry1
No wall-clock read inside the world systems phase, with performance.now stubbed to throw1
A replay applies each recorded input at the tick the capture stamped1
An index query examines at most the candidates in the cells it touches, on a fixed scenario2
Applying a span and then its inverse leaves the replay digest unchanged, for each field and membership entry kind; undoing a span that holds a chunk entry is refused2
An idle world appends no entries, and N walking mobs append at most N position entries in a tick2
No mirror class and no health comparison4
No Entity, GenericEntity or Human class, and no array copy through all() or getEntities()5
No Server import in a handler6
Encode calls for one broadcast occurrence equal the number of codec and compression groups7
Replication visits entities in proportion to log entries, not to the number of entities7
A snapshot plus the log reproduces the replay digest7
A tick makes no wall-clock read and completes without yielding9
No barrel in Prismarine11

Three counts are reported and do not gate: awaits reachable from World.update, change log entries and bytes per tick, and replica entries and bytes. They go through reportProgress.

The slice 7 targets gate from D4f. Encode calls for one broadcast occurrence: NetworkWorldReplicatorOccurrences.test.ts, for every broadcast kind, and NetworkWorldReplicatorEntities.test.ts for a first sight. Visits in proportion to log entries: BlockReplica.test.ts for blocks and block entities and NetworkWorldReplicatorEntities.test.ts for entities. A snapshot plus the log: SessionReplay.test.ts over the scripted session, and WorldChangeTick.test.ts for the scheduled ticks that session does not queue.

Scope notes ​

Handlers take a narrow context and not Server. Other files that take Server stay out of the program, unless a codemod or delegated agents can convert them mechanically with no real risk. Then they go in.

Deferred ​

  • The Script API host. Its before-hooks are guards under ADR 0013. A wall-clock watchdog for long scripts would conflict with ADR 0009, and the host design has to answer that and pick the point in a tick where system.run callbacks run.
  • The low-level plugin form ("mods"). Keep the store's plain-data edge and the explicit exports in mind, and build nothing for it now.
  • Moving replication, a world or pathfinding to another thread. ADR 0011 lists the candidates. A world on its own thread also moves the read lane onto the replica.
  • Retention of the change log, crash recovery from it, and rollback tooling.

Checks to run, not assumed ​

  • Advancement triggers fire inline from the legacy damage and death methods. Reading them from the log changes when they fire, so slices 4 and 7 need a parity check on advancement timing.
  • Rejecting an interaction with an unloaded chunk should match Java. The parity suite has to confirm it in slice 6. Confirmed by D2b's parity family gameplay.block-targets.
  • Counts in place of the 20 ms and 25 ms budgets mean an overloaded host ticks slower and never simulates differently. Slice 1 needs a scenario that shows the tick counter lagging with identical digests.
  • SubChunkRequest can load an evicted chunk that was already announced (PlayerSession.ts:578). The read lane allows the wait, and slice 1 has to confirm the load reaches the world as an input.
  • World.replayStateDigest calls dataEntities().persist() before it hashes, so taking a digest has a side effect. Slice 2 has to remove it or show it changes nothing.
  • The replay test asserts only that one tick has a digest. Slice 1 should make it compare two runs tick by tick before anything depends on it.
  • How the path finder treats a read from an unloaded chunk was not traced.
  • Whether a saved player's inventory record is a deep copy. Confirmed by B4 and again by C4e, which also made the record copy the spawn point; see the C4e notes.
  • Tick functions run from the ticker after the worlds, and a function can act in another dimension. Slice 9 treats a function as an input to the world it runs in and a cross-world command as a message. Confirm against Bedrock when a function runs relative to the tick.
  • The handler lanes were assigned from each handler's direct calls and not from a full trace. Slice 1 reviews the 15 readers and the Disconnect handler one by one. Done by D2a to D2g, which each traced their handlers through what the handlers call; Disconnect moved to the connection lane in D2c.