Documentation · Visual editor · Quick start · Example plugin · Discord
Server Connect lets your Minecraft server add its own menu, buttons, panels and live information to Leaf Client. Your server sends JSON plugin messages on one channel, leafclient:connect, and Leaf Client draws them in its own style. Players who do not use Leaf see nothing and send nothing.
This SDK is the Java side. It builds the JSON for you, checks it against the client limits before anything is sent, batches it, and checks every click that comes back before your handler runs.
The shop tab of the example plugin, in game.
Note
Server Connect is in beta. Messages, fields and limits can still change between Leaf releases. Check the client's caps before you use a feature, and report problems in the Discord.
- Server menu. A full screen menu with your title, icon and up to 8 tabs. Players open it with their Server Connect key (J by default).
- Panels in 8 places. Menu tabs, beside the inventory, beside container screens such as chests, above the chat, on the HUD, in front of the player, at a block, and on an entity or player.
- Elements. Text, buttons, badges, icons, images from a resource pack or an https link, 3D player models, player heads, lists, progress bars, leaderboards, scroll areas, and rows, columns, grids and layers.
- Forms. Text fields, toggles, checkboxes, choices and sliders. Their values arrive with the click, already checked against the panel you sent.
- Button actions. Send the click to your server, run a command, open a link, copy text, suggest a chat message, open the menu or a panel, close, or set a waypoint. Commands, links and copied text always ask the player first.
- Live values and timers.
{v:key},{n:key}and{t:key}placeholders change shown text without sending the UI again. Conditions show, hide or enable elements on the client with no round trip. - Updates in place.
Patchchanges fields of a panel the player already has. - Toasts and waypoints. Leaf notifications with up to 2 buttons, and server waypoints.
- Mod locks.
Session.lockModsturns off chosen client mods on your server and shows the player why. - Checked before sending. A value that breaks a client limit throws
InvalidUiExceptionwith the field path, so the client never receives UI it would reject. - Checked clicks. Your handler runs only for clicks that pass the SDK checks: known panel, matching token, a button that sends clicks, data that one of its
Action.sendactions sends, enabled button, cooldown, valid inputs and, on Paper, reach and world for world panels. - Client input limits. Each player may send 40 messages per second after hello, with bursts of 80, and 32 clicks per second with bursts of 64; messages over the budget are dropped before they are read. Messages must be strict JSON objects, at most 16 KB and 64 levels deep. Typed text arrives with
§, line breaks and line or paragraph separators removed or turned into spaces; text with other control or bidi characters is refused, and so is a starting value with them. Slider values must be on a step, as the client rounds them. Numbers longer than 32 characters make the message invalid. - Cooldowns and tokens. The server enforces button cooldowns as the client shows them:
Button.cooldownstarts one on every accepted press,Button.cooldownLeftand a patchedcooldown_leftset the time left (0 clears it), and a press up to 10% of the cooldown early (at least 250 ms, at most 2 seconds) still counts. A patchedcooldown_leftmust be a number; null is refused. Sending a panel again sends the time left of each running cooldown with it, and after a server switch the SDK sends it again for panels withPanel.Scope.CONNECTION, because the client clears its cooldowns on both. When a panel is sent again or its token is rotated, clicks with a token from the last 4 versions are still accepted for 3 seconds. They run the handler and read the data and inputs of the version they were pressed on, but a button that the current version removed, disabled or put on a cooldown rejects them. - Batching. Messages queued in the same tick go out in one frame, large messages are split into chunks, and sending stays within the client rate limits. Sessions are thread safe.
- Old and new servers. The core and paper modules target Java 8 and run with the Gson 2.2.4 that Spigot 1.8.8 ships. On Folia, each player's messages are sent from that player's own region thread. Behind a proxy,
onReadyruns again after every backend switch.
Build menus and panels without code in the Server Connect editor. The preview runs Leaf Client's own layout engine in the browser, so panels are laid out the way the game lays them out. When the design is ready, press Get code and download the plugin code for Paper / Spigot, Velocity or BungeeCord, or copy the raw JSON.
Editing a menu tab. The structure is on the left, the selected button's properties on the right.
The same tab in the editor's whole screen preview.
The full guide is on the Leaf website: https://leafclient.com/docs/#server-connect. Every example there shows the SDK code and the raw JSON it sends.
| Module | Package | For | Java |
|---|---|---|---|
core |
com.leafclient.connect.sdk |
Builders, sessions, batching and click checks | 8+ |
paper |
com.leafclient.connect.sdk.paper |
Paper (also Spigot, Bukkit, Purpur and Folia), 1.8.8 to 26.x | 8+ |
velocity |
com.leafclient.connect.sdk.velocity |
Velocity proxies | 17+ |
bungee |
com.leafclient.connect.sdk.bungee |
BungeeCord and Waterfall proxies | 8+ |
Each platform module includes core. Shade the one you need into your plugin jar and relocate com.leafclient.connect.sdk to a package of your own, as the snippets below do, so two plugins that both shade the SDK do not load each other's copy. The SDK uses the Gson library your server already ships, and registers the leafclient:connect channel in code, so plugin.yml needs no channels entry.
Run one SDK instance per player connection: one plugin on the backend server, or one plugin on the proxy, not both. Each instance keeps its own rate limits for the client, so two instances talking to the same client can together exceed them. On Velocity and BungeeCord the SDK handles the client's leafclient:connect messages itself and does not forward them to the backend.
The SDK is on JitPack. The examples add paper. For a proxy plugin, use velocity or bungee instead.
Note
0.2.0 renames the bukkit module to paper. When you update from 0.1.x, change the artifact from bukkit to paper, the import from com.leafclient.connect.sdk.bukkit.BukkitConnect to com.leafclient.connect.sdk.paper.PaperConnect, and BukkitConnect to PaperConnect in your code. The module still runs on Spigot and Bukkit, and now on Folia too.
Gradle (build.gradle.kts), then build with ./gradlew shadowJar:
plugins {
java
id("com.gradleup.shadow") version "8.3.5"
}
repositories {
mavenCentral()
maven("https://jitpack.io")
}
dependencies {
implementation("com.github.LeafClientMC.ServerConnectSDK:paper:v0.2.0")
}
tasks.shadowJar {
relocate("com.leafclient.connect.sdk", "com.example.shop.libs.leafconnect")
}Maven (pom.xml), then build with mvn package:
<repositories>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>com.github.LeafClientMC.ServerConnectSDK</groupId>
<artifactId>paper</artifactId>
<version>v0.2.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<relocations>
<relocation>
<pattern>com.leafclient.connect.sdk</pattern>
<shadedPattern>com.example.shop.libs.leafconnect</shadedPattern>
</relocation>
</relocations>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>package com.example.shop;
import com.leafclient.connect.sdk.Condition;
import com.leafclient.connect.sdk.Icon;
import com.leafclient.connect.sdk.Menu;
import com.leafclient.connect.sdk.Panel;
import com.leafclient.connect.sdk.Place;
import com.leafclient.connect.sdk.Session;
import com.leafclient.connect.sdk.Toast;
import com.leafclient.connect.sdk.Ui;
import com.leafclient.connect.sdk.paper.PaperConnect;
import org.bukkit.plugin.java.JavaPlugin;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
public final class ShopPlugin extends JavaPlugin {
private final Map<UUID, Integer> coins = new ConcurrentHashMap<>();
private PaperConnect connect;
@Override
public void onEnable() {
connect = PaperConnect.enable(this);
connect.onReady(this::sendUi);
connect.onQuit(s -> coins.remove(s.id()));
}
@Override
public void onDisable() {
connect.disable();
}
private void sendUi(Session s) {
s.send(Menu.create("Example Network")
.subtitle("{n:coins} coins")
.icon(Icon.item("minecraft:emerald"))
.add(Menu.tab("shop", "Shop").icon(Icon.leaf("shop"))));
s.setValue("coins", coins.computeIfAbsent(s.id(), id -> 1000));
s.send(Panel.create("shop", Place.menu("shop")).content(
Ui.text("&lWelcome!"),
Ui.button("boots", "Speed Boots")
.icon(Icon.item("minecraft:diamond_boots"))
.enabledWhen(Condition.ge("coins", 500))
.onClick(click -> buy(click.session(), "Speed Boots", 500))));
}
private void buy(Session s, String item, int price) {
int balance = coins.getOrDefault(s.id(), 0);
if (balance < price) {
s.send(Toast.create("bought").title("You need " + price + " coins").kind(Toast.Kind.WARN));
return;
}
coins.put(s.id(), balance - price);
s.setValue("coins", balance - price);
s.send(Toast.create("bought").title("Bought " + item).kind(Toast.Kind.SUCCESS));
}
}enabledWhen only greys the button out on the client. A modified client can still send the click, so the click handler checks the balance on the server before it takes the coins.
onReady runs when a Leaf client says hello. Everything sent in the same tick goes out in one batch. On Paper, Spigot and Bukkit, listeners and click handlers run on the main server thread, so you can call the Bukkit API in them.
Folia. Add folia-supported: true to your plugin.yml. PaperConnect detects Folia by itself and sends each player's messages from that player's own region thread, so the SDK calls stay the same. Listeners and click handlers run on the region thread of the player who sent the message. Use Folia's schedulers before you change blocks, entities or players in other regions, and keep shared plugin data in thread safe collections. onQuit listeners run on the thread that handles the player's quit, except during disable(), which ends every session on the thread that disables the plugin; schedule work on the player's entity scheduler there if it touches the player.
package com.example.network;
import com.google.inject.Inject;
import com.leafclient.connect.sdk.Action;
import com.leafclient.connect.sdk.Panel;
import com.leafclient.connect.sdk.Place;
import com.leafclient.connect.sdk.Ui;
import com.leafclient.connect.sdk.velocity.VelocityConnect;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent;
import com.velocitypowered.api.event.proxy.ProxyShutdownEvent;
import com.velocitypowered.api.plugin.Plugin;
import com.velocitypowered.api.proxy.ProxyServer;
import org.slf4j.Logger;
@Plugin(id = "networkbar", name = "NetworkBar", version = "1.0.0")
public final class NetworkBar {
private final ProxyServer proxy;
private final Logger logger;
private VelocityConnect connect;
@Inject
public NetworkBar(ProxyServer proxy, Logger logger) {
this.proxy = proxy;
this.logger = logger;
}
@Subscribe
public void onInit(ProxyInitializeEvent event) {
connect = VelocityConnect.enable(this, proxy, logger);
connect.onReady(s -> s.send(Panel.create("net.bar", Place.chat().order(-1))
.scope(Panel.Scope.CONNECTION)
.content(Ui.row(
Ui.button("net.lobby", "Lobby").on(Action.command("/server lobby")),
Ui.button("net.bedwars", "Bed Wars").on(Action.command("/server bedwars"))))));
}
@Subscribe
public void onShutdown(ProxyShutdownEvent event) {
connect.disable();
}
}Panel.Scope.CONNECTION keeps the bar when the player switches backend servers. On Velocity the SDK listeners, and so your onReady, click handlers and other listeners, run on the player connection's event loop thread: do not block in them, and move slow work such as database calls to the proxy scheduler.
package com.example.network;
import com.leafclient.connect.sdk.Action;
import com.leafclient.connect.sdk.Panel;
import com.leafclient.connect.sdk.Place;
import com.leafclient.connect.sdk.Ui;
import com.leafclient.connect.sdk.bungee.BungeeConnect;
import net.md_5.bungee.api.plugin.Plugin;
public final class NetworkBar extends Plugin {
private BungeeConnect connect;
@Override
public void onEnable() {
connect = BungeeConnect.enable(this);
connect.onReady(s -> s.send(Panel.create("net.bar", Place.chat().order(-1))
.scope(Panel.Scope.CONNECTION)
.content(Ui.row(
Ui.button("net.lobby", "Lobby").on(Action.command("/server lobby")),
Ui.button("net.bedwars", "Bed Wars").on(Action.command("/server bedwars"))))));
}
@Override
public void onDisable() {
connect.disable();
}
}On both proxies, buttons whose only actions are commands send no click to the server. Add Action.send() to their actions if your plugin needs the click. See the platform guides for running a proxy and backends together.
example/ is a complete Paper plugin that also runs on Folia, Spigot and Bukkit: a menu with a shop, a leaderboard and a sign up form, a coins value, and a panel at the world spawn. It is the plugin in the in-game screenshot above.

