-
Notifications
You must be signed in to change notification settings - Fork 0
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-splitbranch. The Fabric-side typed dispatcher is in progress — until then, you can already exchange messages by manually usingClientPlayNetworking.sendwith bytes produced byEnvelope.toBytes().
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.
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 registration —
moddinglib:enveloperegistered 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
playerUuidis 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.
senditself can be called from any thread. -
Error helper —
sendError(player, plugin, action, code)sends back the standardaction + ".error"envelope with a{"error": code}body, used as the convention for failed actions.
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.
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}. |
-
The connection authenticates the player, not the envelope. The
playerUuidfield 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 ownSystem.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.