Portals and dimension travel
Research note, 2026-10-30. Question: what does a Bedrock server have to do so that nether portals and end portals work, and players and entities travel between the Overworld, the Nether and the End, and how much of it does this repository already have.
Sources, in order of trust. Mojang's Java Edition 26.3 server jar, which ships unobfuscated (the cached copy at node_modules/.cache/jsprismarine/java-server/26.3/server.jar, read with javap), is the only first-party source for the rules themselves; Bedrock Dedicated Server is closed, so where Bedrock differs the claim comes from minecraft.wiki and is marked "(wiki)". Mojang's bedrock-protocol-docs schemas (x-minecraft-version 1.26.60-beta.0, protocol 2225) are first-party for the wire. gophertunnel, CloudburstMC/Protocol, Dragonfly, Geyser and Nukkit are reimplementations whose code encodes what a real Bedrock client accepts; they are cited for the packet sequence, where they agree. Commands that reproduce every reading are in "Reproduction". Two notes written the same day cover the neighboring ground and are not repeated here: data-driven-dimensions.md on describing dimensions and generators as data, and dimension-storage-network-config.md on storage keys, the chunk pipeline, the dimension fields of every packet, and the config shape.
Summary
- A nether portal is a vertical obsidian rectangle with an interior 2 to 21 wide and 3 to 21 high (
PortalShape.MIN_WIDTH = 2,MAX_WIDTH = 21,MIN_HEIGHT = 3,MAX_HEIGHT = 21), lit by fire placed inside it. Corners are not checked. Bedrock's block isminecraft:portalwith one state,portal_axisinunknown,x,z; the repository's palette atpackages/minecraft/src/generated/v2193/block-schemas.ts:15429carries exactly that. - Coordinates scale 8:1 horizontally (
DimensionType.coordinateScaleis 8.0 for the Nether, 1.0 elsewhere); Java floors, Dragonfly shifts right by 3, which is the same thing. Y is clamped, not scaled. Java searches 128 blocks in the Overworld and 16 in the Nether (PortalForcer.OVERWORLD_PORTAL_RADIUS = 128,NETHER_PORTAL_RADIUS = 16); Bedrock searches 128 in both (wiki, and Dragonfly'sportalSearchRadius = 128comment). - A player stands in a portal for 80 ticks in survival and 0 in creative (Java gamerules
players_nether_portal_default_delay = 80,players_nether_portal_creative_delay = 0; the wiki says 1 tick on Bedrock). The timer decays by 4 per tick when the player steps out. Non-player entities travel instantly. After travel, the cooldown is 300 ticks for entities, 10 for players and vehicles (Entity.getDimensionChangingDelayreturns 300,PlayerandVehicleEntityreturn 10); Bedrock stores it asPortalCooldownon the entity, starting at 300 (wiki, level format). - An end portal is twelve
end_portal_frameblocks around an open 3x3, every frame withend_portal_eye_bitset andminecraft:cardinal_directionfacing inward. Completing it fills the 3x3 withend_portal. Entering the End regenerates a 5x5 obsidian platform under (100, 49, 0) and places players at (100, 49, 0) facing west, other entities at (100, 50, 0) (ServerLevel.END_SPAWN_POINT = (100, 50, 0), players gety - 1). Entering an end portal in the End returns a player to their respawn point and anything else to world spawn; respawn anchors are not discharged. - The dimension change on the wire is
ChangeDimensionPacket { dimension, position, respawn, loadingScreenId }, then the chunks, thenPlayStatus PlayerSpawn. Since Bedrock 1.19.50 the server has to send aPlayerAction ChangeDimensionAckto the client; the client also sends one back, and since 1.21.20 (protocol 712) it reports the loading screen edges throughServerboundLoadingScreenPacketwith the id the server chose. The client never leaves the loading screen when the dimension id equals the one it is already in, so a same-dimension world switch needs a detour through another id. The repository already has all of these packets with both wire directions, andPlayer.setWorldalready does a four-step detour. - The repository has the dimension definitions (
packages/prismarine/src/world/Dimension.ts), per-dimension LevelDB keys, per-dimension chunk shapes, aWorldManager.getWorldByDimension, aBlockWorld.at(dimension)seam built for portals, aportalFamilythat schedules an 80 tick countdown, fire placement from flint and steel, and an eye of ender that sets the frame bit. It has no frame detection, no portal creation, no cross-world destination, no end platform, no portal cooldown, no entity-in-block dispatch for players (onStepOnis never called by the server), no Nether or End generator, andWorldManager.buildWorldnever passes a dimension, so every world is an Overworld.
1. Nether portals
Frame and interior
Java's PortalShape (net/minecraft/world/level/portal/PortalShape.class) defines the structure. Constants read from the class: MIN_WIDTH = 2, MAX_WIDTH = 21, MIN_HEIGHT = 3, MAX_HEIGHT = 21. These count the interior, so the smallest portal is a 4 by 5 frame and the largest a 23 by 23 frame, matching the wiki's "4x5 minimum, 23x23 maximum" and "corners are not required". The FRAME predicate is a static block state predicate (obsidian; the wiki adds that crying obsidian does not count). The scan is calculateBottomLeft (walk down, then toward the negative side of the axis while the block is empty), calculateWidth (walk along the positive side counting empty blocks until a frame block), calculateHeight (each column up until a frame block, and hasTopFrame across the whole width). isEmpty accepts air, fire and existing portal blocks, which is why re-lighting a frame with a stray fire inside works. isValid() requires the measured width and height inside the limits; isComplete() is isValid() and every interior block already a portal block.
Dragonfly's server/world/portal/scan.go is the same algorithm in Go for the Bedrock block set: it scans the Z axis first, then X, and takes X only when the Z scan is below the minimum area. Its matchesNetherPortalInterior accepts air and fire as interior, and isFrame is minecraft:obsidian.
The portal cannot be lit in the End (wiki). Dragonfly enforces this with tx.World().Dimension() == world.End returning false at the start of every portal routine; Java's BaseFireBlock.onPlace calls PortalShape.findEmptyPortalShape only when inPortalDimension (Overworld or Nether).
Lighting
A fire block appearing inside a valid frame creates the portal. Java does it in BaseFireBlock.onPlace. If the dimension allows portals it tries PortalShape.findEmptyPortalShape(level, pos, Axis.X) (it tries X then Z internally), and if found call createPortalBlocks. The source of the fire is irrelevant: flint and steel, a fire charge, a fireball, lightning, or fire spread (wiki). The frame must be complete before the fire appears; closing the last gap in a frame that already has a fire in it does nothing (wiki).
createPortalBlocks writes nether_portal[axis=<axis>] to every interior position. On Bedrock the block is minecraft:portal and the state is portal_axis: x means the long edge runs east to west, z north to south, and unknown is a placed-by-command state whose direction follows adjacent portals (wiki, block states table; the repository palette lists the three values in that order with unknown as default).
Breaking and ticking
A portal block checks its neighbors on every block update. Java's NetherPortalBlock.updateShape handles it. If the updated face is on the portal's axis or vertical, and the neighbor is neither a portal block nor a frame block, the portal block becomes air. Because the neighbors then update, the whole interior collapses in a chain (wiki: "If any portal block is destroyed, the whole portal is deactivated as adjacent portal blocks are updated"). Dragonfly's Portal.NeighbourUpdateTick instead re-runs the scan from the updated block and, if the portal is no longer framed and complete, flood-fills the connected portal blocks and clears them in one go (connectedNetherPortal), which is simpler to reason about on a server that batches updates.
Portal blocks cannot be mined in survival, are destroyed by explosions, and are replaced by placed water or lava (wiki). Light level 11 (wiki; packages/blocks/vanilla_overlay/blocks/portal.block.json already carries light_emission 11).
Random ticks spawn zombified piglins. Java's NetherPortalBlock.randomTick (bytecode): only when monsters spawn, difficulty is not peaceful and the NETHER_PORTAL_SPAWNS_PIGLINS environment attribute is on (this is how 26.x restricts it to the Overworld); roll random.nextInt(2000) < difficulty.getId() (so 1 in 2000 on easy, 2 in 2000 on normal, 3 in 2000 on hard, matching the wiki's 0.05 / 0.1 / 0.15 percent); require a player close enough for spawning; walk down through portal blocks to the bottom one; check isValidSpawn for a zombified piglin; spawn it one block above and call setPortalCooldown() on it and its vehicle so it does not walk straight back. Bedrock spawns them one block east or south of the portal rather than inside it, ticks every portal block once on activation, and spawns none below y 0 (wiki, Zombified Piglin page, MCPE-98020). The overlay portal.block.json already sets random_tick: true, and portalFamily has no onRandomTick yet.
Travel timer
Java tracks portal contact per entity in PortalProcessor (portalTime, insidePortalThisTick, entryPosition). Every tick Entity.handlePortal runs processPortalCooldown (decrement portalCooldown while above zero), then processPortalTeleportation(level, entity, canUsePortal(false)):
- If the entity was inside this tick, clear the flag, and if allowed, increment
portalTimeand return true when it reachesportal.getPortalTransitionTime(level, entity). - Otherwise
decayTick():portalTime = max(portalTime - 4, 0).hasExpired()isportalTime <= 0, which drops the processor.
NetherPortalBlock.getPortalTransitionTime returns 0 for non-players, and for players max(0, gamerule) where the gamerule is players_nether_portal_creative_delay when abilities.invulnerable (creative) and players_nether_portal_default_delay otherwise. GameRules sets the defaults to 0 and 80. The wiki's "1 tick in creative" on Bedrock describes the same thing, a transfer on the first contact tick.
Entity.canUsePortal(allowPassengers) is "alive, and not a passenger unless allowed". EnderDragon.canUsePortal and WitherBoss.canUsePortal return false; Creaking.canUsePortal is false while heart-bound. Bedrock lets withers use end portals (wiki). Bedrock refuses to teleport a player with a parrot on their shoulder (wiki, MCPE-27950) and does not teleport entities with passengers (wiki, MCPE-47288), where Java carries vehicle and passengers together.
Nukkit's player logic (cn/nukkit/Player.java, checkBlockCollision) is a Bedrock-only server's reading of the same rule. It counts inPortalTicks while any collision block is NETHER_PORTAL, reset to 0 when none is; at tick 40 (1 in creative) precompute the mirror position and pre-generate the destination chunks; at tick 80 (1 in creative) search, create if needed, teleport.
Cooldown after travel
Entity.setPortalCooldown() stores getDimensionChangingDelay() in portalCooldown. The bytecode of Entity.getDimensionChangingDelay is: if the first passenger is a ServerPlayer, return the passenger's delay, else 300. Player.getDimensionChangingDelay returns 10, and VehicleEntity.getDimensionChangingDelay (boats and minecarts) returns 10. So a mob waits 15 seconds, a player half a second, and a boat with a player in it half a second; the wiki's "entity ridden by a player has no cooldown" is this delegation. handlePortal skips an entity whose isOnPortalCooldown() is true, and Entity.handlePortal calls setPortalCooldown() on the entity when a teleport starts, so an entity cannot be bounced back by the destination portal it arrives in.
Bedrock persists this as PortalCooldown (int) on every entity, "initially starts at 300 ticks (15 seconds) after teleportation and counts down to 0" (wiki, Bedrock Edition level format, Entity format). The Java tag is PortalCooldown too (Entity.TAG_PORTAL_COOLDOWN).
Coordinate scaling
DimensionType has a coordinateScale field and a static getTeleportationScale(from, to) that returns from.coordinateScale / to.coordinateScale. The vanilla dimension types read from DimensionTypes.class are: Overworld minY -64, height 384, logicalHeight 384, coordinateScale 1.0; Nether minY 0, height 256, logicalHeight 128, coordinateScale 8.0; End minY 0, height 256, logicalHeight 256, coordinateScale 1.0. NetherPortalBlock.getPortalDestination multiplies the entity's x and z by that scale, clamps through WorldBorder.clampToBounds, and clamps y into the destination's height (Mth.clamp(y, minY, minY + logicalHeight - 1)). Floor division is what makes the Overworld to Nether direction round toward negative infinity (wiki, "Java floor() method"). Dragonfly's translatePortalPosition is x >> 3, z >> 3 one way and x * 8, z * 8 the other, with y clamped to the target range, which gives the same integers.
For this server the only data needed is the scale per dimension (1, 8, 1) and the height range (Dimensions.Overworld -64..320, Nether 0..128, TheEnd 0..256 in packages/prismarine/src/world/Dimension.ts). On Bedrock the Nether is 128 tall and the bedrock ceiling is at 127; Geyser's BedrockDimension.THE_NETHER is (0, 128) and it has a workaround that tells the client it is in the End to allow building above 128, because the client refuses blocks above the Nether's height.
Portal search
PortalForcer.findClosestPortalPosition(pos, isNether, border) (bytecode shows bipush 16 and sipush 128 selected by the boolean) asks the POI manager for nether_portal points of interest within that radius of the scaled position, filters to loaded chunks inside the border, and picks the minimum by squared distance, with a tiebreak on y. The found position is the lowest-left portal block; NetherPortalBlock.getExitPortal then calls BlockUtil.getLargestRectangleAround(pos, axis, 21, Direction.Axis.Y, 21, isPortal) to recover the whole rectangle and createDimensionTransition places the entity with PortalShape.getRelativePosition (the offset inside the source portal, as a fraction of the portal's width and height minus the entity's size, applied to the destination rectangle) and findCollisionFreePosition. If the destination axis is rotated, yaw rotates by 90 degrees (bipush 90 in createDimensionTransition).
Bedrock searches a 128 block radius in both dimensions (wiki, "In Bedrock Edition, the point of interest can be within a 256x256 square block area (128-block radius) in both the Overworld and Nether"). Dragonfly's FindNetherPortal iterates every block in the cube, validates each candidate through NetherPortalFromPos, requires it framed and activated, and keeps the closest portal block by Euclidean distance. Nukkit only searches 16 blocks horizontally over the full height. A server without a POI index should keep a portal index per world (see section 5) instead of scanning 256 x 256 x 128 blocks per trip.
Portal creation
PortalForcer.createPortal(pos, axis) searches a 16 block radius horizontally (bipush 16 in the loop) over the whole world height for the best spot, in two passes: first a 3 wide by 4 tall footprint with the frame, air above and solid below (canHostFrame with the FRAME_BOX 3 constants), then with the depth reduced to 1 (the two-pass structure described on the wiki). It picks the closest valid spot by 3D distance. If nothing is found it forces the portal at the target with y clamped to [70, maxY - 10] (bipush 70 and the -10 reading in the bytecode, and the wiki: "between 70 and 10 less than the world height", 118 in the Nether and 310 in the Overworld), writing a 2x3 obsidian platform (FRAME_BOX_START = -1 .. FRAME_BOX_END = 2, FRAME_WIDTH_START = -1 .. FRAME_WIDTH_END = 3) with air three high above it. Then it writes a 4 by 5 frame including corners (FRAME_WIDTH_START -1 .. FRAME_WIDTH_END 3, FRAME_HEIGHT_START -1 .. FRAME_HEIGHT_END 4) and the six portal blocks, overwriting whatever is there. The axis matches the source portal.
Bedrock differs in two places (wiki): only a player triggers creation ("The incoming entity can be ... only a player in Bedrock Edition"), and a forced platform gets four netherrack blocks flanking the obsidian on each side. Dragonfly's CreateNetherPortal ports Java's search (random starting direction, four directions then two, the forced floating platform), and Dragonfly's PortalTravelComputer.CreatePortal is false for everything but players. Nukkit's spawnPortal writes a fixed 4x5 frame plus a cleared 5x3x4 air box and nothing else.
Linking
Neither edition stores a link between two portals; the link is the search result each time (wiki, "If a portal is deactivated, and the matching portal in the other dimension is used before it is re-activated, a new portal may be created"). Java's only cache is the POI index of portal blocks and the chunk ticket TeleportTransition.PLACE_PORTAL_TICKET places on arrival (PortalForcer.TICKET_RADIUS = 3; the wiki gives its lifetime as 15 seconds, refreshed on each use).
A destination portal taller than 18 blocks spans more than one 8:1 cell, so its two ends can link to two different Overworld portals (wiki). This falls out of the algorithm and needs no special code.
Sounds
Bedrock sound ids: portal.portal (ambient, random), portal.trigger (when a player enters), portal.travel (after teleport) (wiki, Bedrock sound table). Geyser and Dragonfly both send StopSound with "stop all" during the change so the trigger sound does not keep looping.
2. End portals, the End platform, the podium and gateways
Frame ring
end_portal_frame has, on Bedrock, end_portal_eye_bit (0 or 1) and minecraft:cardinal_direction (south, west, north, east, default south) (wiki block states; repository palette line 7173). The direction is the opposite of where the player faced when placing. Using an eye of ender on an empty frame sets the bit (wiki). The frame is unbreakable in survival (wiki).
Java's EndPortalFrameBlock.getOrCreatePortalShape() builds a BlockPattern with aisles ?vvv?, >???<, >???<, >???<, ?^^^?, where each letter is an end_portal_frame with HAS_EYE = true and the matching FACING (south for v, east for >, west for <, north for ^); ? matches anything. The eye of ender item, after setting the bit, checks getOrCreatePortalShape().find(level, pos) and, on a match, writes end_portal to the 3x3 interior (wiki describes this as "destroys any blocks located in the center 3x3 square, including bedrock"). The pattern is searched in rotations, which is the source of the wiki's "24 possible configurations in Java"; Bedrock only accepts the flat ring (wiki). Dragonfly's ActivateEndPortal implements the flat case directly. From the used frame it step inward twice, try the three candidate centers, and require all twelve (position, facing) pairs from expectedEndRingFrames to hold an eye and face the center.
The end_portal block does not depend on the frame to exist (wiki, MC-1174); Dragonfly chooses to remove the interior when the ring breaks anyway (EndPortalRingIntact), which is a server policy, not vanilla. Light level 15 (wiki).
Into the End
EndPortalBlock.getPortalDestination (bytecode): if the current level is the End, the target is the level and position from ServerLevel.getRespawnData() (the world's respawn data, which is the Overworld spawn), else the target is Level.END and ServerLevel.END_SPAWN_POINT, which the static initializer sets to new BlockPos(100, 50, 0). When going to the End it calls EndPlatformFeature.createEndPlatform(level, BlockPos.containing(bottomCenter).below(), true), sets yaw to Direction.WEST.toYRot() and pitch 0, and for a ServerPlayer subtracts (0, 1, 0) from the position. So players arrive at (100.5, 49, 0.5) facing west, everything else at (100.5, 50, 0.5), which matches the wiki's "(100, 49, 0, facing west)" and "other entities ... (100.5, 50, 0.5)". Dragonfly's EndSpawnPosition(player bool) does the same.
EndPlatformFeature.createEndPlatform(level, center, dropBlocks) (bytecode): for dx -2..2, dz -2..2, dy -1..2 (the loop is -1 <= dy < 3), the wanted block is obsidian at dy -1 and air otherwise; if the current block differs it is destroyBlock(pos, true) when dropBlocks is set, then setBlockAndUpdate. The platform is regenerated on every entry (wiki, "Whenever a new entity enters the End, the platform is regenerated"). On Bedrock the platform only generates when an entity uses an end portal, not when teleporting by command (wiki, End Platform page).
Entering an end portal is immediate; there is no countdown (wiki; EndPortalBlock inherits Portal.getPortalTransitionTime's default of 0 and only NetherPortalBlock overrides it). The portal cooldown applies afterwards the same as for nether portals.
Out of the End
From the End, a ServerPlayer goes through findRespawnPositionAndUseSpawnBlock(false, DO_NOTHING), which reads the player's RespawnConfig, resolves the bed or anchor through findRespawnAndUseSpawnBlock(level, config, useCharge = false), and falls back to TeleportTransition.missingRespawnBlock (world spawn with the "missing or obstructed" flag). The false is why a respawn anchor keeps its charge when leaving the End (wiki, "Returning through an End portal is not considered a respawn, and does not use any charges"). Non-players go to adjustSpawnLocation(level, respawnPos) at the Overworld spawn.
Bedrock also shows the credits on first exit and stores HasSeenCredits on the player (wiki, Entity format). The repository's ShowCreditsHandler.ts:11 notes that leaving the End is not modeled.
The podium (exit portal) at (0, y, 0) in the End activates on each dragon death; its portal blocks sit at the height of the highest block at 0,0 (wiki, End Podium page). It is a world generation feature plus a dragon fight state, out of scope for portal travel itself, but its end_portal blocks go through the same return path.
End gateways
TheEndGatewayBlockEntity constants: SPAWN_TIME = 200 (creation beam), COOLDOWN_TIME = 40 (teleport beam and lockout), ATTENTION_INTERVAL = 2400, GATEWAY_HEIGHT_ABOVE_SURFACE = 10, fields age, teleportCooldown, exitPortal, exactTeleport. The twenty central gateways sit at y 75 on a radius of 96 from (0, 0) at floor(96 cos(pi k / 10)), floor(96 sin(pi k / 10)) (wiki). On first use, findExitPortalXZPosTentative walks a 1024 block line out from the origin in 16 block steps, findOrCreateValidTeleportPos picks an end stone block at or above y 30 (bipush 30) or generates an island, and the return gateway is built 10 blocks above. Bedrock stores ExitPortal and Age on the EndGateway block entity (wiki, Block entity format); a gateway placed by a player in the End teleports to (0, 0, 0) and does nothing outside the End (wiki). The block is intradimensional, so it is a teleport, not a dimension change, and needs no ChangeDimensionPacket. The repository maps end_gateway to jsprismarine:portal, though no portal hook handles it yet.
3. The Bedrock wire sequence
Packets
All fields below are read from Mojang's 2225 schemas and cross-checked with gophertunnel and CloudburstMC/Protocol. The repository's layouts in packages/protocol/src/wire/definitions/worldStatus.ts match every one.
| Packet | Id | Fields | Repository |
|---|---|---|---|
ChangeDimensionPacket | 0x3d | Dimension ID (varint32, "0 Overworld, 1 Nether, 2 The End, 3 Undefined"), Position (vec3), Respawn (bool), Loading Screen Id (optional uint32 LE, "Leave empty if there is no loading screen expected on the client. This id needs to be unique and not conflict with any other active loading screens") | worldStatus.ts:63-72, optional since protocol 712 per Cloudburst's ChangeDimensionSerializer_v712 |
PlayerActionPacket | 0x24 | runtime id, action varint32, block position, result position, face | PlayerActionPacket.ts:96-112; ChangeDimensionAck = 14, CreativeDestroyBlock = 13 |
ServerboundLoadingScreenPacket | 0x138 | Loading Screen Packet Type (varint32, StartLoadingScreen = 1, EndLoadingScreen = 2), Loading Screen Id (optional uint32, "This will be set if the server gives us a value. If the server doesn't expect this value, then the client will get disconnected") | ServerboundLoadingScreenPacket.ts, handler logs and calls session.endLoadingScreen(id) |
RespawnPacket | 0x2d | Position vec3, State uint8 (SearchingForSpawn = 0, ReadyToSpawn = 1, ClientReadyToSpawn = 2), runtime id | worldStatus.ts:47-54 |
DimensionDataPacket | 0xb4 | map of id to DimensionDefinition { Minimum Y, Height Range, Generator Type, Dimension Type, Pack Id, Default Biome, Cloud Height (2225), Render Clouds (2225) } | DimensionDataPacket.ts, DimensionDefinition.ts; the two 2225 fields are not carried |
NetworkChunkPublisherUpdatePacket | 0x79 | block position, radius in blocks (varuint32), saved chunks | worldStatus.ts:85-92 |
ChunkRadiusUpdatedPacket | 0x46 | radius (varint32) | ChunkRadiusUpdatedPacket.ts |
SetSpawnPositionPacket | 0x2b | type, block position, Dimension type, spawn block position | worldStatus.ts:35-44 |
LevelChunkPacket | 0x3a | chunk x, z, dimension varint32, ... | levelChunk.ts:61-62, filled from chunk.getDimension().id |
StartGamePacket | dimension varint32 in spawn settings | startGame.ts:254, filled from world.getDimension().id |
Mojang's PlayerActionType description for ChangeDimensionAck reads "Sent in Player Action, this is the one case of the server sending an action to the client to start a dimension change", and for CreativeDestroyBlock "Used to be a ChangeDimension action". Cloudburst still names them DIMENSION_CHANGE_REQUEST_OR_CREATIVE_DESTROY_BLOCK (13) and DIMENSION_CHANGE_SUCCESS (14); gophertunnel names 14 PlayerActionDimensionChangeDone. The repository's enum has CreativeDestroyBlock and ChangeDimensionAck, the Mojang names.
The Dimension enum lives at packages/minecraft/src/Dimension.ts (OVERWORLD = 0, NETHER = 1, END = 2, UNDEFINED = 3). The repository speaks protocols 2193 (1.26.50 and 1.26.51, the default) and 2225 (1.26.60) through Codec2193 and Codec2225 (packages/protocol/src/wire/versions/); none of the packets above changes layout between the two.
What the client expects, and in what order
The three reimplementations agree on the shape. Dragonfly's Session.changeDimension (server/session/session.go):
- Set
changingDimension, allocate a new loading screen id and remember it as expected. ChangeDimension { Dimension, Position (eye position), LoadingScreenID }.StopSound { StopAll }.PlayStatus { PlayerSpawn }.PlayerAction { ActionType: DimensionChangeDone }to the client, with the comment "As of v1.19.50, the dimension ack that is meant to be sent by the client is now sent by the server. The client still sends the ack, but after the server has sent it."- The chunk loader switches world and streams chunks (
handleWorldSwitch);ViewEntityTeleportmoves the player. ServerBoundLoadingScreenHandlerclearschangingDimensionwhen the client reportsEndLoadingScreenwith the expected id, and errors on any other id.
Geyser's DimensionUtils.switchDimension does the same thing with the Java server as the source of truth. It clears chunk, entity and block caches; ChunkRadiusUpdated capped at 32 when needed ("The client locks up when switching dimensions, expecting more chunks than it's getting"); ChangeDimension { dimension, respawn = true, position = (0, 32767, 0) }; remove all effects (the Java server re-sends them); reset weather; StopSound all; PlayerAction DIMENSION_CHANGE_SUCCESS to the client "prior to the client acknowledgement ... send this before chunks are sent"; then sendEmptyChunks(radius 3) because "The client wants sections sent to it before it can successfully respawn". When the client's own DIMENSION_CHANGE_SUCCESS arrives, BedrockPlayerActionTranslator sends PlayStatus PLAYER_SPAWN again ("Sometimes the client doesn't feel like loading") and re-sends attributes.
Nukkit's Player.setDimension sends ChangeDimension { dimension, position, respawn = !isAlive() }, then NetworkChunkPublisherUpdate at the player with chunkRadius << 4, and arms dimensionFix560; the first sendChunk after that sends the server-side DIMENSION_CHANGE_ACK; the client's ack resets the player's position with MovePlayer MODE_RESET.
Gophertunnel's ChangeDimension comment carries the one rule every implementer trips on: "Dimension MUST be a different dimension than the one that the player is currently in. Sending a ChangeDimension packet with a Dimension that the player is currently in will result in a never-ending dimension change screen." Geyser's getTemporaryDimension and JavaRespawnTranslator do a fastSwitchDimension through a fake dimension when the Java world changes but the Bedrock id does not, and Player.setWorld in this repository does a four step Overworld, Nether, Nether, Overworld detour for the same reason. A real portal trip always changes the id, so the detour is only needed for same-dimension world swaps (/tp between two Overworld worlds, or a respawn into the same dimension's other world).
The order that satisfies all three sources, and that the repository should adopt:
- Server-side: remove the player from the source world, despawn tracked entities on the client (
RemoveActorfor each), stop mining, clear the chunk bookkeeping (PlayerSession.clearChunks), and move the player'sPositionto the destination world. ChangeDimensionPacket { dimension: target.id, position: arrival eye position, respawn: false (true when this is a death respawn in another dimension), loadingScreenId: session.beginLoadingScreen() }.StopSoundPacketstopping all sounds.NetworkChunkPublisherUpdatePacketat the arrival position withviewDistance << 4.PlayerActionPacket { runtimeEntityId: player, action: ChangeDimensionAck }to the client.LevelChunkPackets for the arrival area, each withdimension: target.id. The client needs at least the chunks around the arrival position before it will close the screen; Geyser sends empty chunks in a radius of 3 when it has none yet.PlayStatusPacket { PlayerSpawn }after the first batch (Dragonfly sends it right after the ack; Geyser sends it when the client's ack arrives; both work, sending both is harmless).- Wait for the client: it sends
ServerboundLoadingScreenPacket { StartLoadingScreen, id }when the screen opens,PlayerActionPacket ChangeDimensionAckandServerboundLoadingScreenPacket { EndLoadingScreen, id }when it closes. TreatEndLoadingScreenwith the expected id as the end of the transfer: resume movement processing, send aMovePlayerteleport to the exact arrival position (Nukkit'sMODE_RESET), re-send attributes and effects if they were not kept, and raise the server-side "changed dimension" hooks (the repository hasadvancementChangedDimension).
During the change the server should drop the client's PlayerAuthInput positions, which are still in the old world's coordinates until the client acknowledges (the same rule Mojang's docs state for HandledTeleport).
Dimension ids and heights
The id in ChangeDimension, LevelChunk, SubChunk, SetSpawnPosition and StartGame must agree with the dimension's client-side height. The client has three built-in heights: Overworld -64..320, Nether 0..128, End 0..256 (Geyser's BedrockDimension, Dragonfly's dimension.go, and the repository's Dimension.ts all state these). A world that wants a different height has to register a data-driven dimension through DimensionDataPacket before StartGame; Dragonfly's RegisterDimension requires custom ids from 1000 up, sub-chunk aligned ranges within int16, and the Mojang description says the packet is a "Map of dimension identifier to dimension definition" with Dimension Type being the "Dimension runtime ID". Nothing in this research needs a custom dimension; the vanilla three cover portals.
Chunk.clientRangeOf in the repository already pads a shallow dimension's sub chunks to what the client expects for the dimension id, so sending Nether chunks with dimension: 1 works once a Nether world exists.
4. Entity and player state across the transfer
What Java keeps and drops, from ServerPlayer.teleport(TeleportTransition) and Entity.teleport, cross-checked with the wiki and Geyser:
- The entity is removed from the source level with
RemovalReason.CHANGED_DIMENSIONand a fresh copy is added to the destination; its UUID and NBT (inventory, effects, health, hunger, experience, tags) are copied. Geyser removes and re-adds effects only because the Java server re-sends them; on a single Bedrock server the client keeps its own view of inventory and effects acrossChangeDimension, and the repository'ssetWorldcomment says "Whether the client keeps them across the change is unverified". Re-sending attributes (Geyser) and inventory contents after the ack is the safe default. portalCooldownis set on departure (setPortalCooldown()inhandlePortal), so arrival inside the destination portal does not trigger a return trip. ThePortalProcessoris dropped; the countdown starts fresh next time.- Vehicles: Java teleports the vehicle with
transitionAsPassengerfor each passenger and skips passengers whosecanUsePortal(false)is false; Bedrock does not teleport anything with passengers (wiki, MCPE-47288). The simplest Bedrock-faithful rule is to refuse travel for any entity that has or is a passenger. - Items and other entities travel instantly through nether and end portals and are placed by the same algorithm; the wiki's exceptions are the wither and ender dragon (Java), ridden entities (Bedrock), and the creaking bound to a heart.
- Spawn points: a bed sets the spawn only in the Overworld and explodes elsewhere; a respawn anchor only in the Nether and explodes elsewhere (wiki, Dimension page and Respawn Anchor page). Bedrock stores
SpawnX/Y/ZandSpawnDimension(int) on the player (wiki, Entity format).ServerPlayer.findRespawnPositionAndUseSpawnBlocklooks up the level byRespawnConfig.getDimensionOrDefaultand resolves there, so a player who dies in the Nether with an Overworld bed respawns in the Overworld. That is a dimension change on respawn. Dragonfly'sspawnLocationreturns(pos, world, spawnBlockBroken, previousDimension)andhandleWorldSwitchsends aChangeDimensionwhen the world differs, withRespawnmeaningful here. gophertunnel: "The client will send a PlayerAction packet with PlayerActionDimensionChangeRequest if it dies in another dimension, indicating that it needs a DimensionChange packet with Respawn set to true." - The 15 second ticket Java places on the arrival chunk (
PLACE_PORTAL_TICKET) is Java only (wiki marks chunk loading as Java exclusive); a Bedrock server only needs the arrival chunks loaded long enough to place the entity and send them. - Death position and
enteredNetherPositionare advancement bookkeeping; the repository'sadvancementChangedDimension(player, from, to)is the equivalent hook.
5. What the repository has today
Paths are relative to the repository root.
Dimensions and worlds.
packages/prismarine/src/world/Dimension.tsdefinesOverworld { id 0, minY -64, height 384 },Nether { id 1, minY 0, height 128 },TheEnd { id 2, minY 0, height 256 }with helpers anddimensionByName.Worldtakesdimension?: DimensionDefinition(World.ts:320, default Overworld at:507);getDimension()at:2802says the Nether and the End "need portals and their own generators". LevelDB keys includedimension.id(providers/leveldb/LevelDB.ts:115-116, 204-207, 256-283).Chunkis built per dimension and pads to the client range when serialized (chunk/Chunk.ts:23, 507-552).WorldManagerkeepsworldsByDimensionandgetWorldByDimension(dimension)(WorldManager.ts:43-44, 314), butbuildWorld(:161-244) never passesdimension, andWorldDatainconfig/JsonConfigTypes.ts:14-28has no dimension field. Onlylevel-nameis loaded at startup;/debug loadWorldis the only other caller ofloadWorld.- Generators:
FlatandOverworldonly (world/generators/Generators.ts).DataDrivenGeneratoralready acceptsdimensionandnoiseSettings(datadriven/DataDrivenGenerator.ts:106-109), andpackages/worldgenhas Nether noise and a Nether biome source (RandomState.ts:13-16,BiomeSource.ts:118). No End generator.
The player's world change.
Player.setWorld(Player.ts:517-565): stops mining, removes from the old world, despawns tracked entities, moves the position, adds to the new world, then sends fourChangeDimensions (Overworld, Nether, Nether, Overworld, each withPlayStatus PlayerSpawn),sendInitialSpawnChunks,clearChunks,needNewChunks, anotherPlayerSpawn, thenadvancementChangedDimension. The dimension ids are hard-coded rather than taken from the target world, the position is the old position, noStopSound, no server-sideChangeDimensionAck, and nothing waits for the client. The loading screen ids come fromPlayerSession.beginLoadingScreen/endLoadingScreen(PlayerSession.ts:1404-1418), which the tests inPlayer.test.ts:658-720check for uniqueness.PlayerActionHandler.ts:315acceptsChangeDimensionAckand does nothing;ServerboundLoadingScreenHandler.tslogs and closes the id.RespawnHandler.tshandlesClientReadyToSpawnwithin one world./tpacross dimensions (command/host/entities.ts:240-271) callsentity.setWorld(destination)for players only, and builds thesetPositionpromise beforesetWorldruns, so the move applies to the old world.Player.resolveOwnSpawn(Player.ts:1023-1053) ignores a spawn point whose dimension differs from the world the player died in, so a Nether death with an Overworld bed respawns at the Nether world spawn.StoredSpawnPointalready carriesdimension(world/RespawnPoint.ts:25-38).- Player records are per world with no dimension field (
World.savePlayerData,world/PlayerRecords.ts);Player.enablepicks the newest record across loaded worlds. The LevelDBPlayerCodecwritesJSPrismarine.spawnDimensionas a string, not Bedrock's numericDimensionIdorSpawnDimension. There is noPortalCooldown. PlayerSessiontracksloadedChunks,loadingChunks, theChunkSenderqueue andlastChunkX/Z;clearChunks()(:745-755) resets them;flushQueuedChunks(:480-536) sends a publisher update then chunks and marks them loaded after the send, so a batch in flight during a world switch can leak old-world hashes back intoloadedChunks.requestChunkreadsthis.player.getWorld()at delivery time.- Events (
events/Events.ts): no teleport or dimension change event.
Blocks and behaviors.
- Block states for every relevant block are in
packages/minecraft/src/generated/v2193/block-schemas.ts:minecraft:portal(portal_axis),end_portal(none),end_portal_frame(end_portal_eye_bit,minecraft:cardinal_direction),end_gateway(none),obsidian,fire(age),soul_fire(age). - Overlay files in
packages/blocks/vanilla_overlay/blocks/:portal.block.json(no collision, light 11,random_tick: true, behaviorjsprismarine:portal),end_portal.block.json(16x4x16 box, behaviorjsprismarine:portal),end_portal_frame.block.json(eye permutation),fireandsoul_fire(behaviorjsprismarine:portal),end_gateway.block.json(jsprismarine:portal),obsidian(jsprismarine:plain_block). Family assignment rules are inpackages/blocks/src/families.ts:70-80. packages/block-behaviors/src/families/world.ts:79-134portalFamily:onPlaceschedules fire ticks;onStepOnschedules a tick afterPORTAL_DELAY_TICKS = 80(1 forend_portal);onTickonly spawnsminecraft:portal_particle;onNeighborChangedhandles fire survival only;onPlayerInteractsetsend_portal_eye_bit. Its summary says "The destination search across dimensions is left to the server, which owns the dimension list."packages/block-behaviors/README.md:207, 247lists portals as out of scope for the same reason.- The hook set a family can implement is in
packages/block-behaviors/src/family.ts:44-64(onPlace,onBreak,onNeighborChanged,onRandomTick,onTick,onPlayerInteract,onStepOn,onEntityFallOn,onEntity,onRedstoneUpdate,onBlockStateChange);WorldActions(src/world.ts:151-269) givesgetBlock,setBlock,entitiesAt,scheduleTick,spawnParticle,playSound,notifyNeighbors,random;BlockWorld.at(dimension)(src/world.ts:278-281) exists "so a portal can look at the other side". - The server dispatches
onPlace,onBlockStateChange,onNeighborChanged,onTick,onRandomTick,onPlayerInteractandonEntity(packages/prismarine/src/block/contentAdapter.ts), but neveronStepOnoronEntityFallOn, so the portal countdown cannot start on a running server.entitiesAtmatches the feet cell only (entity/worldContentAdapter.ts:72-79). The one per-tick box-against-blocks check is the data-drivenminecraft:inside_block_notifier(packages/entities/src/components/vanilla.ts:2714-2779). - Fire placement works end to end:
fireStarterBehavior(packages/item-behaviors/src/behaviors/interaction.ts:171-192) emitsignite,packages/prismarine/src/item/contentAdapter.ts:188-196writesminecraft:firethroughblocks.set, which dispatchesonPlace. The eye of ender sets the frame bit (item-behaviors/src/behaviors/navigation.ts:115-130). - Scheduled ticks (
engine-systemsTickScheduler, dispatched byworld/blockTicks.tsonly if the block identifier is unchanged) and random ticks (world/randomTicks.ts) both exist.
Tests and harnesses.
packages/prismarine/src/Player.test.tsfakePlayer()builds a realPlayerover stubbedServer,WorldandClientConnectionwhosesendDataPacketpushes intosent, and thesetWorldtests assert packet order byfindIndexoversent.packages/prismarine/src/network/PlayerSessionChunks.test.tsbuilds aPlayerSessionover a stub connection and inspectssession.sender.packages/prismarine/src/network/handler/PlayerActionHandler.test.tshasfakeSession(gametype).packages/block-behaviors/src/harness.tsruns families against an in-memory world and is the only placeonStepOnis called today.packages/client/src/Login.test.tsboots a realServeron 127.0.0.1:19277 with a tempJSP_DIRand logs in with the realClient;client.getSession()is aClientSessionemittingpacketandpacket:<id>.packages/paritydeclares families insrc/families.ts; CI fails when a declared family has no test. There is aprotocol.*group (negotiation,wire-roundtrip) and anintegration.*group.
6. Proposed design
Where it lives
Two layers, matching the package boundary the repository already drew for portals ("the server owns the dimension list").
Portal geometry and rules go in packages/block-behaviors, which already owns portalFamily, has a BlockWorld.at(dimension) seam and a test harness with an in-memory world. A new src/rules/portal.ts (frame scan, ring match, creation footprint search, platform writer) keeps the pure functions beside rules/fire.ts, and portalFamily grows the hooks that call them. Nothing in this layer knows about packets or World.
Travel goes in packages/prismarine/src/world/portal/ as a DimensionTravel service owned by the server (one instance, reached from Server), because it needs WorldManager, Player, PlayerSession and the entity runtime. It is the only code that sends ChangeDimensionPacket. A new package is not warranted: the logic depends on World and PlayerSession, which are prismarine's, and the deep-module guidance in .agents/skills/codebase-design argues for a small public interface over a new boundary.
Data-driven parts
The rules can be stated as data per dimension and per portal block, read from one table rather than branches:
// packages/prismarine/src/world/portal/rules.ts
export const DIMENSION_TRAVEL = {
'minecraft:overworld': { coordinateScale: 1, portalSearchRadius: 128, allowsNetherPortal: true, respawnBlock: 'bed' },
'minecraft:nether': { coordinateScale: 8, portalSearchRadius: 128, allowsNetherPortal: true, respawnBlock: 'respawn_anchor' },
'minecraft:the_end': { coordinateScale: 1, portalSearchRadius: 0, allowsNetherPortal: false, respawnBlock: undefined }
} as const;
export const PORTALS = {
'minecraft:portal': { kind: 'nether', frame: ['minecraft:obsidian'], links: { 'minecraft:overworld': 'minecraft:nether', 'minecraft:nether': 'minecraft:overworld', 'minecraft:the_end': 'minecraft:overworld' }, delayTicks: { survival: 80, creative: 0 } },
'minecraft:end_portal': { kind: 'end', frame: ['minecraft:end_portal_frame'], links: { 'minecraft:overworld': 'minecraft:the_end', 'minecraft:nether': 'minecraft:the_end', 'minecraft:the_end': 'minecraft:overworld' }, delayTicks: { survival: 0, creative: 0 } }
} as const;The height range comes from Dimensions (already data). The Bedrock-specific choices (128 in both directions, player-only creation, netherrack flanks, no passengers, the_end nether portal leads to the Overworld) are entries in this table rather than conditions, and a test can assert the table against the wiki values. The frame block list and the portal block list are what packages/blocks/src/families.ts already uses to assign jsprismarine:portal, so the two should derive from one source.
Cooldown values (300 for entities, 10 for players and vehicles) belong on the entity side as a portalCooldown field with the Bedrock NBT name PortalCooldown, saved by PlayerCodec and the entity codec.
Block layer
portalFamily gains:
onPlaceforfireandsoul_fire: after scheduling the fire tick, calltryLightNetherPortal(world, pos). It scans both axes with Java'sPortalShapealgorithm over the frame list, and on a valid frame writesminecraft:portalwith the rightportal_axisto every interior block. Refuse inthe_end.onNeighborChangedforportal: if the changed face is vertical or on the portal's axis and the neighbor is neither portal nor frame, flood-fill the connected portal blocks of that axis and set them to air (Dragonfly'sconnectedNetherPortal). Chaining through neighbor updates like Java also works but relies onnotifyNeighborsordering.onRandomTickforportalinminecraft:overworld: the piglin rollrandom() * 2000 < difficultyId, spawn one block east or south of the bottom portal block (Bedrock placement), set itsPortalCooldownto 300.onPlayerInteractand the eye of ender item: after settingend_portal_eye_bit, runtryActivateEndPortal(world, pos)with the flat twelve-frame ring match; on success writeend_portalto the 3x3 and playblock.end_portal.spawn(Dragonfly mapssound.EndPortalCreatedtoSoundEventEndPortalCreated).onBreakforend_portal_framein creative: optional ring re-check; vanilla leavesend_portalblocks standing (wiki, MC-1174), so the default is to leave them.
Travel is not started from the family. The server dispatches a per-tick "entity inside portal block" signal (below) and the family's job ends at geometry.
Server layer
// packages/prismarine/src/world/portal/DimensionTravel.ts
export interface Arrival { world: World; position: Vector3; yaw: number; pitch: number; createdPortal: boolean }
export class DimensionTravel {
/** Called every tick for every entity whose box overlaps a portal block. */
touch(entity: Entity, portal: PortalBlockRef): void;
/** Resolves where a trip from `from` through `portal` lands, creating a portal or the End platform when the rules say so. */
resolve(entity: Entity, from: World, portal: PortalBlockRef): Promise<Arrival | undefined>;
/** Moves the entity, players through the wire sequence in section 3. */
transfer(entity: Entity, arrival: Arrival): Promise<void>;
}- A
PortalContactper entity (portalTime,insideThisTick,entryPosition,cooldown) mirrors Java'sPortalProcessorplusportalCooldown:touchsetsinsideThisTick; the tick step increments or decays by 4; atdelayTicks[gamemode]it callsresolvethentransfer;cooldownblockstouchand counts down. The entity'sEntity.updatealready countsinvulnerableTicks, which is the place to add the two counters. - The per-tick signal:
World.updatealready rebuilds the entity grid; after that, for each entity, iterate the cells of its box (asinside_block_notifierdoes) and, for the first cell whose block is inPORTALS, calltouch. Players need this too, so it belongs inWorld, not in the data-driven runtime. DispatchingonStepOnfor players as well would also wake the existing family countdown, but the countdown is better owned by the entity than by a block tick. resolvefor a nether portal:target = WorldManager.getWorldByDimension(PORTALS[block].links[from.dimension]); scale the entry block position byfrom.scale / to.scalewithMath.floor, clamp y to the target range; query the target'sPortalIndex(below) withinportalSearchRadius; if found, take the rectangle around the closest portal block and apply Java's relative-position mapping; else, players only, run the creation search (16 block radius, two passes, forced platform clamp[70, maxY - 10], netherrack flanks) and write the 4x5 portal with the source axis. Arrival position is the bottom center of the portal block, yaw rotated 90 degrees when the axis differs.resolvefor an end portal: entering the End writes the 5x5 platform under (100, 49, 0) and returns (100.5, 49, 0.5, yaw west) for players, y 50 for others; leaving the End returns the player's resolved spawn point across dimensions (section 4) or the Overworld spawn.transferfor players: the eight steps of section 3, implemented inPlayerSession.changeWorld(world, arrival)which replaces the body ofPlayer.setWorld. Keep the detour only whenfrom.dimension.id === to.dimension.id. Hold apendingDimensionChangeon the session soPlayerAuthInputis ignored untilEndLoadingScreenwith the expected id arrives, then send the teleportMovePlayer, attributes and inventory. Save the player record to the old world before the move, and writeDimensionIdinto the record.transferfor other entities:from.removeEntity,to.addEntityat the arrival position,cooldown = 300.PortalIndex: a per-world set of portal block positions maintained from the block write path (World.writeBlockRuntimeId) and from chunk load, so the search is a distance scan over an index rather than a block scan. Java's POI index is this; Dragonfly scans blocks and is slow for 128.- Respawn:
Player.resolveOwnSpawnresolves the spawn inWorldManager.getWorldByDimension(spawn.dimension)instead of refusing, andRespawnHandlercallstransferwithrespawn: truewhen the resolved world differs. - Config:
WorldData.dimension?: 'overworld' | 'nether' | 'the_end', passed throughbuildWorldtonew World({ dimension }). Startup loads every configured world, not onlylevel-name, or the service loads the destination lazily on first use. ANethergenerator built onDataDrivenGeneratorwithnoiseSettings: 'minecraft:nether'and the worldgen package's Nether biome source, and anEndgenerator (end islands noise is in Java's datapack asminecraft:endnoise settings), are prerequisites for a trip to land on terrain; aFlatNether is enough to develop and test the travel itself.
Events
Add PlayerChangeDimensionEvent (cancelable, with from, to, arrival) and EntityPortalEnterEvent to events/Events.ts, following PlayerMoveEvent. Dragonfly exposes HandlePortalActivate, HandlePortalCreate and HandlePortalTravel; the second is what lets a plugin stop a portal from being carved into a build.
Test plan
Unit tests, beside the code.
packages/block-behaviors/src/rules/portal.test.tsthroughharness.ts: frame scan accepts 4x5 without corners, 23x23, fire anywhere inside; rejects 3x5, 24x23, a missing side block, crying obsidian; axis detection for both orientations; lighting writes the rightportal_axis; a frame broken at one block clears the whole interior; a fire placed in the End lights nothing; the twelve-frame ring with one frame facing outward or missing an eye does not activate, the complete ring writes nineend_portal; the piglin roll per difficulty.packages/prismarine/src/world/portal/scale.test.ts:floor(x / 8)for negative coordinates (-29.9 to -4),x * 8back, y clamp into 0..127 and -64..319, world border clamp.search.test.tsover an in-memoryPortalIndex: closest by Euclidean distance including y, radius 128 in both dimensions, a portal just outside the radius ignored, the two-ends-of-a-tall-portal case.create.test.ts: the two-pass footprint search picks the closest valid spot, the forced platform is clamped to[70, maxY - 10], includes netherrack flanks, and the written frame is 4x5 with corners and six portal blocks on the source axis.contact.test.ts: 80 ticks in survival, 0 in creative, decay by 4 when stepping out, cooldown 300 for a mob and 10 for a player blocks re-entry, a passenger never travels.DimensionTravel.test.tsusingfakePlayer()fromPlayer.test.ts: the packet order (RemoveActor before ChangeDimension; ChangeDimension carries the target id and the arrival position; StopSound, NetworkChunkPublisherUpdate, server PlayerAction ChangeDimensionAck, LevelChunk with the target dimension id, PlayStatus PlayerSpawn); a single ChangeDimension for a real dimension change and the detour only for a same-dimension world swap; PlayerAuthInput ignored until the client's EndLoadingScreen with the expected id; a wrong id logged and ignored; respawn across dimensions sendsrespawn: true.
Integration, through the real client (packages/client/src/Login.test.ts pattern): boot a server with two Flat worlds configured as overworld and nether, log in, place the player in a hand-written lit portal (set blocks through World.setBlockByName), advance 80 ticks, and assert on client.getSession() events that ChangeDimensionPacket arrived with dimension 1, chunks arrived with dimension 1, and that after the client replies ServerboundLoadingScreen EndLoadingScreen the server's Player.getWorld() is the Nether world and a MovePlayer teleport followed. The client package needs codecs for ChangeDimension, ServerboundLoadingScreen and PlayerAction on its side; ClientPacketRegistry reports unknown ids, so the test can assert they are known.
Parity (packages/parity): this is a data-driven compatibility behavior, so a blocks.portals row (every block the PORTALS and frame tables name exists in the Bedrock palette with the states the rules read, and the Bedrock portal_axis values are exactly unknown, x, z) and a protocol.dimension-change row (the sequence from DimensionTravel.transfer round-trips through Codec2193 and Codec2225 and decodes to the same fields) keep the families represented.
7. Implementation chunks
Each chunk is a reviewable commit or two, in dependency order.
- Dimension-aware worlds.
WorldData.dimension,buildWorldpasses it, startup loads every configured world,WorldManager.getWorldByDimensionbecomes the lookup travel uses,Chunkand LevelDB already follow. AFlatNether proves it. Fix the/tpordering bug while there. - Portal geometry in
block-behaviors:rules/portal.tswith the frame scan, ring match, creation footprint and platform writer, plusportalFamilyhooks for lighting, collapse, end portal activation and the piglin random tick. Harness tests. Overlay unchanged;end_gatewayalready carriesjsprismarine:portal. - Entity portal contact:
portalCooldownandPortalContactonEntity, the per-tick box-overlap dispatch inWorld.update,PortalCooldownpersisted by both codecs. Unit tests for the timers. DimensionTravel.resolve: the rules table, scaling,PortalIndex, search, creation, End platform and End exit. Unit tests against the Java constants in this note.- The wire sequence:
PlayerSession.changeWorldreplacingPlayer.setWorld's body, the pending-change state keyed by loading screen id,PlayerActionHandlerandServerboundLoadingScreenHandlercompleting it, input dropped during the change, attributes and inventory re-sent after.fakePlayer()packet-order tests, and the client integration test. - Respawn across dimensions:
resolveOwnSpawnresolving in the spawn's world,RespawnHandlertransferring withrespawn: true, bed and anchor dimension checks with the explosion left for later. - Non-player travel: items and mobs through
transfer, Bedrock's no-passenger rule, and thePlayerChangeDimensionEventandEntityPortalEnterEvent. - Terrain: a
Nethergenerator over the existing Nether noise and biome source, then anEndgenerator; the podium, dragon fight and gateways are a separate effort. - Parity rows
blocks.portalsandprotocol.dimension-change, a Changeset for@jsprismarine/block-behaviors,@jsprismarine/blocks(iffamilies.tschanges),@jsprismarine/entitiesand@jsprismarine/prismarine, and an entry for this note indocs/research/README.md.
Reproduction
# Wiki pages as wikitext
for p in Nether_portal "Nether_portal_(block)" End_portal End_portal_frame "End_Portal_(block)" \
"End_gateway_(block)" End_gateway Exit_portal Obsidian_platform The_End The_Nether \
Respawn_anchor Bed Dimension Zombified_Piglin \
"Bedrock_Edition_level_format/Entity_format" "Bedrock_Edition_level_format/Block_entity_format"; do
curl -sL "https://minecraft.wiki/api.php?action=parse&page=$p&prop=wikitext&format=json&formatversion=2&redirects=1" -o "/tmp/portal-research/$(echo "$p" | tr '/' '_').json"
done
# Java 26.3 server jar, already cached by `pnpm dev` (ensure:worldgen)
cd /tmp/portal-research/jar
cp node_modules/.cache/jsprismarine/java-server/26.3/server.jar bundler.jar
unzip -o -q bundler.jar 'META-INF/versions/*/server-*.jar' && cp META-INF/versions/*/server-*.jar inner.jar
J=/Library/Java/JavaVirtualMachines/temurin-27.jdk/Contents/Home/bin/javap
$J -p -constants -classpath inner.jar net/minecraft/world/level/portal/PortalShape
$J -p -constants -classpath inner.jar net/minecraft/world/level/portal/PortalForcer
$J -p -c -constants -classpath inner.jar net/minecraft/world/entity/Entity | sed -n '/public int getDimensionChangingDelay/,/^$/p'
$J -p -c -constants -classpath inner.jar net/minecraft/world/entity/player/Player | sed -n '/public int getDimensionChangingDelay/,/^$/p'
$J -p -c -constants -classpath inner.jar net/minecraft/world/entity/vehicle/VehicleEntity | sed -n '/public int getDimensionChangingDelay/,/^$/p'
$J -p -c -constants -classpath inner.jar net/minecraft/world/entity/PortalProcessor
$J -p -c -constants -classpath inner.jar net/minecraft/world/level/block/NetherPortalBlock
$J -p -c -constants -classpath inner.jar net/minecraft/world/level/block/EndPortalBlock
$J -p -c -constants -classpath inner.jar net/minecraft/world/level/block/EndPortalFrameBlock | grep -E 'ldc|HAS_EYE'
$J -p -c -constants -classpath inner.jar net/minecraft/world/level/levelgen/feature/EndPlatformFeature
$J -p -c -constants -classpath inner.jar net/minecraft/server/level/ServerLevel | grep -B3 -A3 END_SPAWN_POINT
$J -p -c -constants -classpath inner.jar net/minecraft/world/level/gamerules/GameRules | grep -A6 nether_portal
$J -p -c -constants -classpath inner.jar net/minecraft/data/worldgen/DimensionTypes | grep -E 'ldc2_w|sipush|bipush'
$J -p -constants -classpath inner.jar net/minecraft/world/level/block/entity/TheEndGatewayBlockEntity
# Mojang protocol schemas (main branch, 1.26.60-beta.0 / 2225)
for f in ChangeDimensionPacketPayload PlayerActionType ServerboundLoadingScreenPacketPayload RespawnPacketPayload \
DimensionDefinition DimensionDataPacketPayload SetSpawnPositionPacketPayload NetworkChunkPublisherUpdatePacketPayload; do
curl -sL "https://raw.githubusercontent.com/Mojang/bedrock-protocol-docs/main/json/$f.json"
done
# Reimplementations
curl -sL https://raw.githubusercontent.com/Sandertv/gophertunnel/master/minecraft/protocol/packet/change_dimension.go
curl -sL https://raw.githubusercontent.com/CloudburstMC/Protocol/3.0/bedrock-codec/src/main/java/org/cloudburstmc/protocol/bedrock/codec/v712/serializer/ChangeDimensionSerializer_v712.java
curl -sL https://raw.githubusercontent.com/df-mc/dragonfly/master/server/session/session.go | sed -n '/func (s \*Session) handleWorldSwitch/,/^func (s \*Session) ChunkRadius/p'
curl -sL https://raw.githubusercontent.com/df-mc/dragonfly/master/server/world/portal/nether.go
curl -sL https://raw.githubusercontent.com/df-mc/dragonfly/master/server/world/portal/end.go
curl -sL https://raw.githubusercontent.com/df-mc/dragonfly/master/server/entity/travel.go
curl -sL https://raw.githubusercontent.com/GeyserMC/Geyser/master/core/src/main/java/org/geysermc/geyser/util/DimensionUtils.java
curl -sL https://raw.githubusercontent.com/GeyserMC/Geyser/master/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/entity/player/BedrockPlayerActionTranslator.java
curl -sL https://raw.githubusercontent.com/CloudburstMC/Nukkit/master/src/main/java/cn/nukkit/Player.java | sed -n '/public void setDimension/,/^ }/p'
# This repository
rg -n "portal|ChangeDimension|loadingScreen|getDimension\(\)" packages/prismarine/src packages/block-behaviors/src packages/protocol/src --glob '!*.test.ts' | head -100
sed -n 15429,15431p packages/minecraft/src/generated/v2193/block-schemas.ts
sed -n 7173,7176p packages/minecraft/src/generated/v2193/block-schemas.ts