Chapter 12, Wires

A welcome message, a reply, and a careful handshake, custom packets with OSL networking.

Most of what your mod needs to sync, RetroAPI already syncs (we'll list it at the end of this chapter). But sometimes you have a genuinely custom message to send across the wire, a greeting on join, a button press, a bit of machine state vanilla never imagined. That's a custom packet, and OSL's networking layer makes them small and safe.

We'll build the simplest possible round trip: server says hello, client says thanks. It touches every piece you need for anything bigger.

The plan

The showcase spells out the whole exchange in a comment at the top of ExampleNetworking.java, read it before the code, it's the map for the rest of the chapter:

src/main/java/com/example/example_mod/ExampleNetworking.java
/**
 * Custom packets via OSL networking (osl-networking), a tiny welcome/ping round trip:
 *
 *   1. When a player joins a dedicated server, the server sends a WELCOME packet
 *      (sent from ExampleModServer.welcomeOnceReady, driven by ServerPlayerTickMixin).
 *   2. The client listener (in ExampleModClient) logs it and replies with a PING.
 *   3. The server listener (in ExampleModServer) logs the reply.
 *
 * Only the channel ids live here, because this class must be loadable on BOTH sides:
 * client-only code (ClientPlayNetworking) stays in ExampleModClient and server-only
 * code (ServerPlayNetworking) stays in ExampleModServer.
 *
 * Note: Beta 1.7.3 has no integrated server, so custom packets only flow when playing
 * on a dedicated server, in singleplayer these channels simply stay quiet.
 */

Beta 1.7.3 has no integrated server. In modern Minecraft, singleplayer runs a hidden local server and your packets flow between it and your client. Not here, singleplayer in beta runs entirely inside the client (Chapter 2), with no second side to talk to. So these channels stay completely quiet in singleplayer; custom packets only matter on a dedicated server. Chapter 22 shows how to actually stand one up and test this.

Step 1, declare the channels (both sides)

A channel is a named pipe with a direction. Both the client and the server must agree the channel exists, so the channel ids live in a class that loads on both sides, the common ExampleNetworking. Crucially, only the ids live here: the moment you reference ClientPlayNetworking or ServerPlayNetworking in this class, it would fail to load on the side that lacks those classes. Keep it neutral.

src/main/java/com/example/example_mod/ExampleNetworking.java
public final class ExampleNetworking {
    private ExampleNetworking() {}

    // register(id, serverToClient, clientToServer), directions a channel allows.
    public static final NamespacedIdentifier WELCOME = ChannelRegistry.register(
        ChannelIdentifiers.from(ExampleMod.MOD_ID, "welcome"), true, false);

    public static final NamespacedIdentifier PING = ChannelRegistry.register(
        ChannelIdentifiers.from(ExampleMod.MOD_ID, "ping"), false, true);

    /**
     * The channels above are registered by this class's static initializer; calling
     * this from ExampleMod.init() just forces that to happen at a predictable time.
     */
    public static void registerChannels() {
    }
}

Read the two boolean flags: WELCOME is (true, false), server-to-client only. PING is (false, true), client-to-server only. The directions you declare are the directions OSL will allow; a channel that only flows one way can't be misused the other.

The registrations happen in the class's static initializer. The empty registerChannels() method exists only so ExampleMod.init() can call it and force the class to load at a predictable moment, touching the class triggers its static block:

src/main/java/com/example/example_mod/ExampleMod.java
// -------------------------------------------------------------- networking --
// Channels must be registered on both sides, so this happens here in the
// common entrypoint. The client listener lives in ExampleModClient, the server
// listener in ExampleModServer.
ExampleNetworking.registerChannels();

Step 2, the client side

The client registers a listener for WELCOME and, when it arrives, replies on PING. Both live in ExampleModClient, the client entrypoint, because ClientPlayNetworking only exists on the client. The listener lambda receives a Context and a PacketBuffer:

src/main/java/com/example/example_mod/ExampleModClient.java
// Listen for the server's WELCOME packet (see ExampleNetworking for the plan).
// The lambda gets a Context (ctx.minecraft(), ctx.ensureOnMainThread(), ...)
// and a PacketBuffer with read/write methods for all the usual types.
ClientPlayNetworking.registerListener(ExampleNetworking.WELCOME, (ctx, buf) -> {
    String message = buf.readString();
    ExampleMod.LOGGER.info("[client] the server says: {}", message);

    // Reply on our client->server channel.
    ClientPlayNetworking.send(ExampleNetworking.PING, reply ->
        reply.writeString("Thanks for the welcome!"));
});

Two objects do all the work here:

  • Context (ctx), your handle on the client: ctx.minecraft() gives the Minecraft instance, and ctx.ensureOnMainThread() hops you onto the render thread when you need to touch the world or GUI safely. Packets arrive on the network thread; anything that mutates game state should go through ensureOnMainThread.
  • PacketBuffer (buf), the wire format. It has matched read/write pairs for every common type: readString/writeString, readInt/writeInt, readVarInt/writeVarInt (compact ints, prefer these for small numbers), readBoolean, readByteArray, and more. Read in the same order you wrote.

Sending client-to-server is one call: ClientPlayNetworking.send(channel, writerLambda), you get a fresh buffer to fill, OSL ships it on the channel you named.

Step 3, the server side

The server has two jobs: send the welcome (once, at the right moment) and listen for the reply. Both live in ExampleModServer, the dedicated-server entrypoint, because ServerPlayNetworking and ServerPlayerEntity only exist there. Here's the whole class:

src/main/java/com/example/example_mod/ExampleModServer.java
public class ExampleModServer implements ServerModInitializer {

    /** Players we've already welcomed (weak so it empties itself on logout). */
    private static final Set<ServerPlayerEntity> WELCOMED =
        Collections.newSetFromMap(new WeakHashMap<ServerPlayerEntity, Boolean>());

    @Override
    public void initServer() {
        // Listen for the client's PING reply (see ExampleNetworking for the plan).
        // ctx gives you the sending player, the server, and the network handler.
        ServerPlayNetworking.registerListener(ExampleNetworking.PING, (ctx, buf) -> {
            String note = buf.readString();
            ExampleMod.LOGGER.info("[server] ping from {}: {}", ctx.player().name, note);
        });
    }

    /**
     * Sends the WELCOME packet to a player exactly once per login. Called every
     * server tick from ServerPlayerTickMixin; we wait until OSL's channel handshake
     * with this client is done (isPlayReady) before sending, then remember them.
     */
    public static void welcomeOnceReady(ServerPlayerEntity player) {
        if (WELCOMED.contains(player)) {
            return;
        }
        if (!ServerPlayNetworking.isPlayReady(player, ExampleNetworking.WELCOME)) {
            return; // handshake not finished yet (or the client doesn't have the mod)
        }
        WELCOMED.add(player);
        ServerPlayNetworking.send(player, ExampleNetworking.WELCOME, buf ->
            buf.writeString("Welcome to the server, " + player.name + "!"));
    }
}

On the server, the Context gives you the other end of the conversation: ctx.player() is the ServerPlayerEntity who sent the packet, and ctx.server() is the MinecraftServer itself. Sending server-to-client takes the target player as its first argument: ServerPlayNetworking.send(player, channel, writerLambda).

The handshake gate, isPlayReady

You can't fire a packet at a player the instant they appear. OSL runs its own channel handshake after a client connects, and until it finishes the client isn't listening on your channel yet. welcomeOnceReady guards on exactly that:

ServerPlayNetworking.isPlayReady(player, channel) returns true only once the handshake for that channel has completed. It also returns false when the client simply doesn't have your mod, a vanilla client never registers your channel, so it never becomes ready. That's by design: you cannot send a custom packet to a vanilla client. The send is silently impossible, and the once-guard never fires for them. Your server stays compatible with vanilla clients for free.

Because the channel might not be ready on the first tick (or the fifth), welcomeOnceReady is called every tick and gates itself: skip if already welcomed, skip if not ready, otherwise send and remember. The WeakHashMap-backed set is the once-guard, weak references so a player who logs out drops out of the set automatically, and gets welcomed again on their next login.

What drives the send

That per-tick call comes from a server-side tick mixin. ServerPlayerEntity.tick() runs every tick for every logged-in player, which makes it a reliable "this player is in the world" hook:

src/main/java/com/example/example_mod/mixin/ServerPlayerTickMixin.java
@Mixin(ServerPlayerEntity.class)
public class ServerPlayerTickMixin {

    @Inject(method = "tick", at = @At("HEAD"))
    private void example_mod$onTick(CallbackInfo ci) {
        ServerPlayerEntity self = (ServerPlayerEntity) (Object) this;
        ExamplePlayerSetup.run((PlayerEntity) self);
        ExampleModServer.welcomeOnceReady(self);
    }
}

Mixins are Chapter 13's whole subject, for now, notice that this one lives in the "server" list of the mixin config so it never loads on a client, where ServerPlayerEntity doesn't exist. That sided-loading discipline is the recurring theme of networking code.

What RetroAPI already syncs for you

Before you reach for a custom packet, check whether RetroAPI is already handling it. A huge amount of the multiplayer plumbing that would otherwise be your problem is done:

ChannelWhat it syncs
id_syncBlock ids, so the server's remapped block ids match the client's.
dim_syncDimensions, your registered dimension reaches the client (Chapter 10).
chunk_extExtended block data per chunk, beta's block array is small; this carries the overflow.
entity_spawn / entity_mobCustom entity spawns, so your mob appears on every client (Chapter 7).
open_guiContainer GUIs, opening your crate or freezer on a remote client.
container propertiesThe vanilla property mechanism, the freezer's progress bars ride this (Chapter 6).

The lesson: you usually only need a custom packet for genuinely custom mechanics, a thing the game has no concept of. Blocks, items, entities, dimensions, GUIs, machine progress: all synced already. Reach for ChannelRegistry only when you've confirmed nothing above covers you.

Packets are the careful way mods talk. Next we open the toolbox that lets a mod change anything the game does, and learn to do it without breaking every other mod in the pack.