MultiLoader 1.21+ · Part 16

Networking and Custom Packets (MultiLoader 1.21+)

ADVANCED MULTILOADER 1.21-1.21.1 25 min read · Aug 24, 2026
MultiLoader 1.21+ · 28 parts
1 Getting Started with MultiLoader 1.21+ 2 Setting Up RegistrationUtils 3 Creating Items (MultiLoader 1.21+) 4 Creating Blocks (MultiLoader 1.21+) 5 Data Generation: Block & Item Models (MultiLoader 1.21+) 6 Data Generation: Block Loot Tables (MultiLoader 1.21+) 7 Data Generation: Crafting Recipes (MultiLoader 1.21+) 8 Data Generation: Block & Item Tags (MultiLoader 1.21+) 9 Custom Food Items (MultiLoader 1.21+) 10 Custom Tools (MultiLoader 1.21+) 11 Custom Armour (MultiLoader 1.21+) 12 Block Entities (MultiLoader 1.21+) 13 Config Files (MultiLoader 1.21+) 14 Custom Sounds (MultiLoader 1.21+) 15 Events and Listeners (MultiLoader 1.21+) 16 Networking and Custom Packets (MultiLoader 1.21+) 17 Data Generation: Advancements (MultiLoader 1.21+) 18 Data Generation: Language Files (MultiLoader 1.21+) 19 Custom Entities (MultiLoader 1.21+) 20 Ore Generation (MultiLoader 1.21+) 21 Introduction to Mixins (MultiLoader 1.21+) 22 Custom Particles (MultiLoader 1.21+) 23 Menus & Screens (MultiLoader 1.21+) 24 Key Bindings (MultiLoader 1.21+) 25 Custom Potion Effects (MultiLoader 1.21+) 26 Custom Enchantments (MultiLoader 1.21+) 27 Custom Commands (MultiLoader 1.21+) 28 Custom Biomes (MultiLoader 1.21+)

Custom packets let you synchronise data between the server and clients. This tutorial uses Common Network, a multiloader networking library that lets you define, register, and send packets entirely from your common module - no loader-specific registration or send helpers required.

NOTE
This tutorial shows a single server-to-client (S2C) packet. The same pattern applies to client-to-server (C2S) packets - the handler checks Side.SERVER instead and uses ctx.sender() to access the sending player.

Gradle Setup

Add the BlameJared Maven repository and the Common Network dependency to each subproject. The common module uses the API-only artifact at compile time; the loader subprojects depend on their full runtime artifact, which pulls the common artifact in transitively:

groovy
// in every subproject's repositories block
repositories {
maven { url = "https://maven.blamejared.com" }
}
// common/build.gradle
dependencies {
compileOnly "mysticdrew:common-networking-common:VERSION-1.21.1"
}
// fabric/build.gradle
dependencies {
modImplementation "mysticdrew:common-networking-fabric:VERSION-1.21.1"
}
// neoforge/build.gradle
dependencies {
implementation "mysticdrew:common-networking-neoforge:VERSION-1.21.1"
}

Replace VERSION with the latest mod version from the Modrinth page. At the time of writing the latest 1.21.1 build is 1.0.21, giving a full dependency string of e.g. mysticdrew:common-networking-fabric:1.0.21-1.21.1.

Defining the Packet

Create a network package in your common module and add a plain class for your packet. The class does not implement CustomPacketPayload directly - the library wraps it internally before sending. You need four components: a channel identifier, a stream codec, encode/decode methods, and a static handler:

java
public class ExampleS2CPacket {
public static final ResourceLocation CHANNEL =
ResourceLocation.fromNamespaceAndPath(Constants.MOD_ID, "example_s2c");
public static final StreamCodec<FriendlyByteBuf, ExampleS2CPacket> STREAM_CODEC =
StreamCodec.ofMember(ExampleS2CPacket::encode, ExampleS2CPacket::new);
private final int value;
private final String message;
public ExampleS2CPacket(int value, String message) {
this.value = value;
this.message = message;
}
// Decode constructor - called by StreamCodec when reading from the wire
public ExampleS2CPacket(FriendlyByteBuf buf) {
this.value = buf.readInt();
this.message = buf.readUtf();
}
public void encode(FriendlyByteBuf buf) {
buf.writeInt(value);
buf.writeUtf(message);
}
public static CustomPacketPayload.Type<CustomPacketPayload> type() {
return new CustomPacketPayload.Type<>(CHANNEL);
}
public static void handle(PacketContext<ExampleS2CPacket> ctx) {
if (Side.CLIENT.equals(ctx.side())) {
Minecraft.getInstance().execute(() -> {
if (Minecraft.getInstance().player != null) {
Minecraft.getInstance().player.sendSystemMessage(
Component.literal("[S2C] value=" + ctx.message().value
+ " msg=" + ctx.message().message));
}
});
}
}
}

StreamCodec.ofMember takes two method references: the instance encode method and the decode constructor. Fields must be written and read in the same order - mismatching them will silently corrupt data. Minecraft.getInstance().execute() schedules the handler body onto the main game thread, which is required before touching any game state.

NOTE
ctx.message() returns the packet instance. ctx.sender() returns the ServerPlayer when handling on the server side, and is null on the client side.

Registering Packets

Create a registration class in your common module and call it from your common mod initialiser. A single Network.registerPacket call covers both loaders - no loader-specific event listeners or split client/server registration needed:

java
// common/network/PacketRegistration.java
public class PacketRegistration {
public static void init() {
Network.registerPacket(
ExampleS2CPacket.type(),
ExampleS2CPacket.class,
ExampleS2CPacket.STREAM_CODEC,
ExampleS2CPacket::handle);
}
}
java
// In your common mod constructor or init method
PacketRegistration.init();

Network.registerPacket returns the Network instance, so you can chain registrations for multiple packets in one block:

java
Network
.registerPacket(ExampleS2CPacket.type(), ExampleS2CPacket.class,
ExampleS2CPacket.STREAM_CODEC, ExampleS2CPacket::handle)
.registerPacket(ExampleC2SPacket.type(), ExampleC2SPacket.class,
ExampleC2SPacket.STREAM_CODEC, ExampleC2SPacket::handle);

Sending Packets

Use the Dispatcher static facade from your common module. No loader-specific helper classes or service-loader wiring is needed:

java
// Server → specific client
Dispatcher.sendToClient(new ExampleS2CPacket(42, "hello"), player);
// Server → all online players
Dispatcher.sendToAllClients(new ExampleS2CPacket(42, "hello"), server);
// Server → all players currently in a level
Dispatcher.sendToClientsInLevel(new ExampleS2CPacket(42, "hello"), serverLevel);
// Client → server (for C2S packets)
Dispatcher.sendToServer(new ExampleC2SPacket());

Dispatcher also provides spatial variants - sendToClientsInRange, sendToClientsLoadingChunk, and sendToClientsLoadingPos - for scoping delivery to nearby players. If you prefer instance-based calls, Network.getNetworkHandler() exposes the same methods on a NetworkHandler object.

Testing

Add a temporary block interaction handler that fires the packet when a player right-clicks your New Dirt block. Join a local world and right-click the block - you should see the chat message appear, confirming the packet was sent from the server and received on the client. If nothing appears, check the console for unregistered payload errors, which indicate PacketRegistration.init() was not called during startup.

You can see an example of this in the source code on GitHub below.

You can find the source for this tutorial here:

View Source on GitHub
NEXT IN SERIES

Data Generation: Advancements (MultiLoader 1.21+)

Build an advancement tree with a root node and child advancements using AdvancementProvider.AdvancementGenerator, attach InventoryChangeTrigger and PlacedBlockTrigger criteria, and wire recipe unlock rewards.

Continue →