Skip to content

Networking

“Perrier” edited this page May 23, 2026 · 1 revision

Networking

Typed client↔server messaging between the Fabric mod and a Spigot/Paper plugin. Everything goes through a single plugin-messaging channel (moddinglib:envelope) using a shared Envelope codec, so any mod plugin can ride on top without registering its own channel.

Status: the protocol module and the Spigot side ship today on the feature/multi-module-split branch. The Fabric-side typed dispatcher is in progress — until then, you can already exchange messages by manually using ClientPlayNetworking.send with bytes produced by Envelope.toBytes().

Why an envelope, not per-feature payloads

Each Bukkit plugin would normally register its own plugin-messaging channel. That works but means:

  • Every channel name has to be agreed on out-of-band between client and server.
  • Every plugin reimplements the same byte-fiddling (VarInt strings, UUID).
  • A client modified to spoof a UUID inside the payload would have to be caught individually in each handler.

The Envelope collapses all of that into one shared format:

plugin     : VarInt-prefixed UTF-8   ("saofr-economy", "quests", ...)
playerUuid : two longs (msb, lsb)
action     : VarInt-prefixed UTF-8   ("shop.buy", "quest.complete", ...)
json       : VarInt-prefixed UTF-8   (the payload body, Gson-serialized)

It's byte-identical to what Minecraft's protocol uses for the same primitives, so a buffer written on the Fabric side via PacketByteBuf is read on the Spigot side via DataInputStream without translation.

Server-side: writing a plugin handler

Depend on moddinglib-spigot (see Getting Started), declare depend: [ModdingLib] in your plugin.yml, then in your plugin's onEnable:

import org.bukkit.plugin.java.JavaPlugin;
import org.triggersstudio.moddinglib.spigot.ModdingLibApi;
import com.google.gson.Gson;
import java.util.Map;

public final class MyEconomyPlugin extends JavaPlugin {

    private static final Gson GSON = new Gson();

    @Override
    public void onEnable() {
        ModdingLibApi api = ModdingLibApi.get();

        api.register("saofr-economy", "shop.buy", (player, json) -> {
            BuyRequest req = GSON.fromJson(json, BuyRequest.class);
            if (req == null || req.qty <= 0) {
                api.sendError(player, "saofr-economy", "shop.buy", "invalid_request");
                return;
            }

            // ... validate cooldown, balance, debit, give item ...

            api.send(player, "saofr-economy", "shop.buy.ok",
                    Map.of("newBalance", currentBalance(player)));
        });
    }

    private record BuyRequest(String itemId, int qty) {}
}

What the API does for you:

  • Channel registrationmoddinglib:envelope registered for in and out at plugin startup, unregistered on disable.
  • Envelope parse + serialize — you only see the JSON payload string.
  • Anti-spoof — every incoming envelope's playerUuid is checked against the sending connection's UUID. Mismatches are dropped with a warning before your handler is called.
  • Rate limiting — token-bucket per player (20 messages / second by default) before dispatch.
  • Main-thread hop — your handler runs on the Bukkit main thread, so it's safe to touch inventories, scoreboards, the world. send itself can be called from any thread.
  • Error helpersendError(player, plugin, action, code) sends back the standard action + ".error" envelope with a {"error": code} body, used as the convention for failed actions.

Client-side: sending and receiving (Fabric)

While the typed dispatcher lands, the manual path:

// Send: serialize an Envelope into the bytes Fabric's CustomPayload uses.
Envelope env = new Envelope(
        "saofr-economy",
        client.player.getUuid(),
        "shop.buy",
        gson.toJson(new BuyRequest(itemId, qty)));

PacketByteBuf buf = PacketByteBufs.create();
buf.writeBytes(env.toBytes());
ClientPlayNetworking.send(new CustomEnvelopePayload(buf));

// Receive: deserialize incoming bytes through Envelope.fromBytes.
ClientPlayNetworking.registerGlobalReceiver(CustomEnvelopePayload.ID, (payload, ctx) -> {
    Envelope received = Envelope.fromBytes(payload.bytes());
    // dispatch on received.routingKey()
});

The dedicated ModdingLibClient.Networking facade replacing this manual code is the last piece pending on the feature branch.

Field reference

org.triggersstudio.moddinglib.protocol.Envelope (record):

Field Type Purpose
plugin String Sub-system identifier — by convention the consuming plugin's name ("saofr-economy", "quests", ...). Routes to the right handler.
playerUuid UUID The player this message is about. Server-side, checked against the connection's UUID before dispatch — used for audit/logging only, never for authentication.
action String Routing key within the sub-system. shop.buy, quest.complete, etc. The error convention is action + ".error".
json String The actual payload, Gson-serialized. Always validate with a typed fromJson(json, Schema.class) — never trust raw JsonObject.

org.triggersstudio.moddinglib.spigot.ModdingLibApi:

Method Effect
register(pluginId, action, handler) Subscribe to incoming pluginId:action messages. Re-registering overwrites the previous handler.
unregister(pluginId, action) Remove a handler. No-op if absent.
send(player, pluginId, action, payload) Serialize payload via Gson and send it to player. Safe from any thread.
sendError(player, pluginId, action, code) Shortcut: sends action + ".error" with {"error": code}.

Security model

  • The connection authenticates the player, not the envelope. The playerUuid field is for logging and cross-server bridging; a spoof attempt is automatically rejected.
  • The server is authoritative for every state change. Client sends intents ("I want to buy X"), server validates and applies. Never trust client timestamps — for cooldowns, the server stamps with its own System.currentTimeMillis().
  • JSON is parsed strictly. Always deserialize to a typed record/POJO, not a free-form JsonObject. Reject + log on parse failure or missing fields.
  • Rate-limit aggressively. The built-in 20/sec is shared across all actions. If a single sub-system has a higher legitimate rate, add its own counter on top.

For broader background on the design rationale, see the Concepts page.

Clone this wiki locally