diff --git a/api/src/main/java/com/velocitypowered/api/event/player/PlayerClientLoadedWorldEvent.java b/api/src/main/java/com/velocitypowered/api/event/player/PlayerClientLoadedWorldEvent.java
new file mode 100644
index 0000000000..940e19271d
--- /dev/null
+++ b/api/src/main/java/com/velocitypowered/api/event/player/PlayerClientLoadedWorldEvent.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2018-2025 Velocity Contributors
+ *
+ * The Velocity API is licensed under the terms of the MIT License. For more details,
+ * reference the LICENSE file in the api top-level directory.
+ */
+
+package com.velocitypowered.api.event.player;
+
+import com.google.common.annotations.Beta;
+import com.google.common.base.Preconditions;
+import com.velocitypowered.api.proxy.Player;
+
+/**
+ * Called when a player is marked as loaded by the client.
+ *
+ *
This event is fired when the player explicitly notifies the server after loading the world (closing the downloading terrain screen)
+ *
+ * @implNote Unlike Paper this event will not fire due to a timeout.
+ * Though plugins can implement a timeout by scheduling a task in {@link ServerPostConnectEvent}
+ * and checking {@link com.velocitypowered.api.proxy.ServerConnection#isClientLoaded()}.
+ * @sinceMinecraft 1.21.4
+ * @since 3.4.0
+ */
+@Beta
+public final class PlayerClientLoadedWorldEvent {
+
+ private final Player player;
+
+ public PlayerClientLoadedWorldEvent(Player player) {
+ this.player = Preconditions.checkNotNull(player, "player");
+ }
+
+ public Player getPlayer() {
+ return player;
+ }
+
+ @Override
+ public String toString() {
+ return "PlayerClientLoadedWorldEvent{"
+ + "player=" + player
+ + '}';
+ }
+
+}
\ No newline at end of file
diff --git a/api/src/main/java/com/velocitypowered/api/proxy/ServerConnection.java b/api/src/main/java/com/velocitypowered/api/proxy/ServerConnection.java
index c408e83058..64f75c54f8 100644
--- a/api/src/main/java/com/velocitypowered/api/proxy/ServerConnection.java
+++ b/api/src/main/java/com/velocitypowered/api/proxy/ServerConnection.java
@@ -40,6 +40,16 @@ public interface ServerConnection extends ChannelMessageSource, ChannelMessageSi
*/
ServerInfo getServerInfo();
+ /**
+ * Returns whether the client notified this connection of having loaded the world.
+ *
+ * @return true if the client has loaded the world
+ * @implNote This is purely client-dependent; see {@link com.velocitypowered.api.event.player.PlayerClientLoadedWorldEvent}.
+ * @sinceMinecraft 1.21.4
+ * @since 3.4.0
+ */
+ boolean isClientLoaded();
+
/**
* Returns the player that this connection is associated with.
*
diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftSessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftSessionHandler.java
index d1101d6a5a..00783dbf7f 100644
--- a/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftSessionHandler.java
+++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftSessionHandler.java
@@ -53,6 +53,7 @@
import com.velocitypowered.proxy.protocol.packet.ServerLoginSuccessPacket;
import com.velocitypowered.proxy.protocol.packet.ServerboundCookieResponsePacket;
import com.velocitypowered.proxy.protocol.packet.ServerboundCustomClickActionPacket;
+import com.velocitypowered.proxy.protocol.packet.ServerboundPlayerLoadedPacket;
import com.velocitypowered.proxy.protocol.packet.SetCompressionPacket;
import com.velocitypowered.proxy.protocol.packet.StatusPingPacket;
import com.velocitypowered.proxy.protocol.packet.StatusRequestPacket;
@@ -200,6 +201,10 @@ default boolean handle(RespawnPacket packet) {
return false;
}
+ default boolean handle(ServerboundPlayerLoadedPacket packet) {
+ return false;
+ }
+
default boolean handle(ServerLoginPacket packet) {
return false;
}
diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/VelocityServerConnection.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/VelocityServerConnection.java
index 71ebd7bc78..e5c44d8718 100644
--- a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/VelocityServerConnection.java
+++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/VelocityServerConnection.java
@@ -68,6 +68,7 @@ public class VelocityServerConnection implements MinecraftConnectionAssociation,
private final VelocityServer server;
private @Nullable MinecraftConnection connection;
private boolean hasCompletedJoin = false;
+ private boolean clientLoaded = false; // 1.21.4+
private boolean gracefulDisconnect = false;
private BackendConnectionPhase connectionPhase = BackendConnectionPhases.UNKNOWN;
private final Map pendingPings = new HashMap<>();
@@ -318,6 +319,15 @@ public void completeJoin() {
}
}
+ public void setClientLoaded(boolean clientLoaded) {
+ this.clientLoaded = clientLoaded;
+ }
+
+ @Override
+ public boolean isClientLoaded() {
+ return clientLoaded;
+ }
+
boolean isGracefulDisconnect() {
return gracefulDisconnect;
}
diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientPlaySessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientPlaySessionHandler.java
index 17eb708b36..160153116b 100644
--- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientPlaySessionHandler.java
+++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientPlaySessionHandler.java
@@ -25,6 +25,7 @@
import com.velocitypowered.api.event.player.CookieReceiveEvent;
import com.velocitypowered.api.event.player.PlayerChannelRegisterEvent;
import com.velocitypowered.api.event.player.PlayerClientBrandEvent;
+import com.velocitypowered.api.event.player.PlayerClientLoadedWorldEvent;
import com.velocitypowered.api.event.player.TabCompleteEvent;
import com.velocitypowered.api.event.player.configuration.PlayerEnteredConfigurationEvent;
import com.velocitypowered.api.network.ProtocolVersion;
@@ -48,6 +49,7 @@
import com.velocitypowered.proxy.protocol.packet.ResourcePackResponsePacket;
import com.velocitypowered.proxy.protocol.packet.RespawnPacket;
import com.velocitypowered.proxy.protocol.packet.ServerboundCookieResponsePacket;
+import com.velocitypowered.proxy.protocol.packet.ServerboundPlayerLoadedPacket;
import com.velocitypowered.proxy.protocol.packet.TabCompleteRequestPacket;
import com.velocitypowered.proxy.protocol.packet.TabCompleteResponsePacket;
import com.velocitypowered.proxy.protocol.packet.TabCompleteResponsePacket.Offer;
@@ -195,6 +197,20 @@ public boolean handle(ClientSettingsPacket packet) {
return true; // will forward onto the server
}
+ @Override
+ public boolean handle(ServerboundPlayerLoadedPacket packet) {
+ VelocityServerConnection serverConnection = player.getConnectedServer();
+ if (serverConnection == null) {
+ // No server connection yet, probably transitioning - shouldn't be possible with a vanilla client
+ return true;
+ }
+ if (!serverConnection.isClientLoaded()) {
+ serverConnection.setClientLoaded(true);
+ server.getEventManager().fireAndForget(new PlayerClientLoadedWorldEvent(player));
+ }
+ return true;
+ }
+
@Override
public boolean handle(SessionPlayerCommandPacket packet) {
if (player.getCurrentServer().isEmpty()) {
diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java
index 67b010879a..6c5b7ee5d8 100644
--- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java
+++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java
@@ -87,6 +87,7 @@
import com.velocitypowered.proxy.protocol.packet.ServerLoginSuccessPacket;
import com.velocitypowered.proxy.protocol.packet.ServerboundCookieResponsePacket;
import com.velocitypowered.proxy.protocol.packet.ServerboundCustomClickActionPacket;
+import com.velocitypowered.proxy.protocol.packet.ServerboundPlayerLoadedPacket;
import com.velocitypowered.proxy.protocol.packet.SetCompressionPacket;
import com.velocitypowered.proxy.protocol.packet.StatusPingPacket;
import com.velocitypowered.proxy.protocol.packet.StatusRequestPacket;
@@ -336,6 +337,11 @@ public enum StateRegistry {
map(0x11, MINECRAFT_1_20_5, false),
map(0x13, MINECRAFT_1_21_2, false),
map(0x14, MINECRAFT_1_21_6, false));
+ serverbound.register(
+ ServerboundPlayerLoadedPacket.class,
+ () -> ServerboundPlayerLoadedPacket.INSTANCE,
+ map(0x2A, MINECRAFT_1_21_4, false),
+ map(0x2B, MINECRAFT_1_21_6, false));
serverbound.register(
PluginMessagePacket.class,
PluginMessagePacket::new,
diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/ServerboundPlayerLoadedPacket.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/ServerboundPlayerLoadedPacket.java
new file mode 100644
index 0000000000..c3a6f0a641
--- /dev/null
+++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/ServerboundPlayerLoadedPacket.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2025 Velocity Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package com.velocitypowered.proxy.protocol.packet;
+
+import com.velocitypowered.api.network.ProtocolVersion;
+import com.velocitypowered.proxy.connection.MinecraftSessionHandler;
+import com.velocitypowered.proxy.protocol.MinecraftPacket;
+import com.velocitypowered.proxy.protocol.ProtocolUtils;
+import io.netty.buffer.ByteBuf;
+
+public class ServerboundPlayerLoadedPacket implements MinecraftPacket {
+
+ public static final ServerboundPlayerLoadedPacket INSTANCE = new ServerboundPlayerLoadedPacket();
+
+ private ServerboundPlayerLoadedPacket() {}
+
+ @Override
+ public void decode(ByteBuf buf, ProtocolUtils.Direction direction, ProtocolVersion version) {}
+
+ @Override
+ public void encode(ByteBuf buf, ProtocolUtils.Direction direction, ProtocolVersion version) {}
+
+ @Override
+ public int decodeExpectedMaxLength(ByteBuf buf, ProtocolUtils.Direction direction, ProtocolVersion version) {
+ return 0;
+ }
+
+ @Override
+ public boolean handle(MinecraftSessionHandler handler) {
+ return handler.handle(this);
+ }
+
+}