RapunzelLib is a Java 21 library for Minecraft plugins/mods that share code across Paper, Velocity, Fabric, NeoForge, and Sponge (Vanilla). It provides a small, platform-neutral API plus platform-specific bootstraps and implementations.
api- platform-neutral interfaces and value typesbom- Gradle/Maven BOM to keep module versions alignedcommon- default context + common implementations (e.g. YAML MiniMessage messages)commands- Brigadier helpers (e.g. list-argument parsing) + platform-neutralRCommandSourcecommands-paper- optional Paper adapters (e.g. Bukkit sender →RCommandSource)commands-fabric- optional Fabric adapters (e.g.CommandSourceStack→RCommandSource)commands-neoforge- optional NeoForge adapterscommands-sponge- optional Sponge adapters (e.g. Sponge command source →RCommandSource)events- platform-neutral game action events (sync cancellable + sync/async observers)events-paper- Paper bridge (Bukkit/Paper listeners → Rapunzel events)events-fabric- Fabric bridge (Fabric callbacks → Rapunzel events)events-neoforge- NeoForge bridgeevents-sponge- Sponge bridgeplatform-paper- Paper bootstrap + wrappers + scheduler + plugin-messaging transportplatform-velocity- Velocity bootstrap + wrappers + scheduler + plugin-messaging transport + proxy-side respondersplatform-fabric- Fabric bootstrap + wrappers + scheduler (network defaults to in-memory)platform-neoforge- NeoForge bootstrap + wrappers + scheduler (network defaults to in-memory)platform-sponge- Sponge bootstrap + wrappers + scheduler (network defaults to in-memory)network- transport abstraction (Messenger), typed event bus, plugin-messaging payloads, Redis Pub/Sub transport, file sync, network infodatabase-spool- wrapper aroundde.t14d3:spoolfor simple DB usage + DB-backed network outbox (DbQueuedMessenger)gradle-plugin-de.t14d3.rapunzellibGradle plugin (templates, message validation, multi-server runner integration)tool-server-runner- CLI used by the Gradle plugin
This project is published under:
- Group:
de.t14d3.rapunzellib - BOM:
bom - ArtifactIds (match module names):
api,common,commands,commands-paper,commands-fabric,commands-neoforge,commands-sponge,events,events-paper,events-fabric,events-neoforge,events-sponge,network,database-spool,platform-paper,platform-velocity,platform-fabric,platform-neoforge,platform-sponge,tool-server-runner
Note: jar file names are prefixed rapunzellib-..., but Maven artifactIds are the plain module names above.
Repository (as used by this build):
repositories {
maven("https://maven.t14d3.de/releases")
maven("https://maven.t14d3.de/snapshots") // for *-SNAPSHOT versions
}Pick the platform module you run on:
dependencies {
implementation(platform("de.t14d3.rapunzellib:bom:<version>"))
implementation("de.t14d3.rapunzellib:platform-paper")
// or: platform-velocity / platform-fabric / platform-neoforge / platform-sponge
}Optional add-ons:
dependencies {
implementation("de.t14d3.rapunzellib:network")
implementation("de.t14d3.rapunzellib:database-spool")
}Most non-api modules also publish a shaded classifier (e.g. ...:<version>:shaded) which relocates common libraries (notably SnakeYAML and/or Gson) to reduce dependency conflicts.
Fabric also publishes an unremapped dev jar as :dev-shaded.
The gradle-plugin module publishes a Gradle plugin with developer tools:
rapunzellibValidateMessages: validates message key usage in compiled bytecode against yourmessages.yml.rapunzellibRunServers/rapunzellibRunPerfServers: runs a local Velocity + multiple Paper backends usingtool-server-runner.rapunzellibInitTemplate: generates a small starter template (messages/config/Example.java).
Example:
plugins {
id("de.t14d3.rapunzellib") version "<version>"
}
rapunzellib {
messagesFile.set(layout.projectDirectory.file("src/main/resources/messages.yml"))
failOnUnusedKeys.set(true)
}More details: gradle-plugin/README.md.
Rapunzel is a global holder for exactly one RapunzelContext.
The platform bootstraps create a context (DefaultRapunzelContext), register default services, and call Rapunzel.bootstrap(owner, ctx).
In shared runtimes where multiple components may call a platform bootstrap, the bootstraps use Rapunzel.bootstrapOrAcquire(owner, ...) so only the first call creates the global context; later calls simply acquire a lease for the existing context.
If multiple plugins/mods shade RapunzelLib, which exact version provides the shared classes depends on the platform's classloading behavior and load order; keep your consumers reasonably version-aligned.
Paper
import de.t14d3.rapunzellib.Rapunzel;
import de.t14d3.rapunzellib.platform.paper.PaperRapunzelBootstrap;
import org.bukkit.plugin.java.JavaPlugin;
public final class MyPlugin extends JavaPlugin {
@Override public void onEnable() {
PaperRapunzelBootstrap.bootstrap(this);
}
@Override public void onDisable() {
Rapunzel.shutdown(this);
}
}Velocity
import com.velocitypowered.api.proxy.ProxyServer;
import de.t14d3.rapunzellib.Rapunzel;
import de.t14d3.rapunzellib.platform.velocity.VelocityRapunzelBootstrap;
import org.slf4j.Logger;
import java.nio.file.Path;
public final class MyPlugin {
public MyPlugin(ProxyServer proxy, Logger logger, Path dataDir) {
VelocityRapunzelBootstrap.bootstrap(this, proxy, logger, dataDir);
}
public void shutdown() {
Rapunzel.shutdown(this);
}
}Fabric
import de.t14d3.rapunzellib.Rapunzel;
import de.t14d3.rapunzellib.platform.fabric.FabricRapunzelBootstrap;
import net.minecraft.server.MinecraftServer;
public final class MyModBootstrap {
private static final String MOD_ID = "my_mod";
public static void init(MinecraftServer server) {
FabricRapunzelBootstrap.bootstrap(MOD_ID, server, MyModBootstrap.class);
}
public static void shutdown() {
Rapunzel.shutdown(MOD_ID);
}
}Sponge (Vanilla)
import de.t14d3.rapunzellib.Rapunzel;
import de.t14d3.rapunzellib.platform.sponge.SpongeRapunzelBootstrap;
import org.spongepowered.api.Server;
import org.spongepowered.plugin.PluginContainer;
import java.nio.file.Path;
public final class MySpongePlugin {
public void startup(PluginContainer container, Server server, Path dataDir) {
SpongeRapunzelBootstrap.bootstrap(container, dataDir, server);
}
public void shutdown() {
Rapunzel.shutdown(this);
}
}After bootstrapping:
Rapunzel.context()returns the currentRapunzelContext(throws if not bootstrapped)Rapunzel.players(),Rapunzel.worlds(),Rapunzel.blocks()are convenience shortcuts- If multiple components share the same runtime, use
Rapunzel.acquire(owner)/Rapunzel.shutdown(owner)to keep the context alive until the last owner releases.
RapunzelContext.services() is a minimal ServiceRegistry (type → instance).
The provided DefaultRapunzelContext supports:
- registering instances (and auto-closing
AutoCloseableservices) - registering additional
AutoCloseables - closing all closeables in reverse registration order
The default config implementation is SnakeYamlConfigService / SnakeYamlConfig.
Key properties:
- Loads YAML from disk.
- Optional default resource merge: missing keys and comments are merged from a classpath YAML resource.
- Typed getters with coercion for common types (e.g. primitives,
UUID,Duration, enums) plus:MessageKeyRBlockPosfrom{x,y,z}or"x y z"/"x,y,z"RWorldReffrom{name,key}or"minecraft:overworld"RLocationfrom a map (world,x,y,z, optionalyaw/pitch)- Java records backed by YAML maps
- Reads/writes simple YAML comments (per dotted path).
Example:
import de.t14d3.rapunzellib.Rapunzel;
import de.t14d3.rapunzellib.config.YamlConfig;
import de.t14d3.rapunzellib.objects.RBlockPos;
import java.time.Duration;
YamlConfig cfg = Rapunzel.context().configs().load(
Rapunzel.context().dataDirectory().resolve("config.yml"),
"config.yml"
);
Duration cooldown = cfg.getDuration("cooldown", Duration.ofSeconds(5));
RBlockPos spawn = cfg.getBlockPos("spawn", new RBlockPos(0, 64, 0));
cfg.set("cooldown", Duration.ofMinutes(2));
cfg.setComment("cooldown", "Accepts 10s/5m/2h/1d or ISO-8601 like PT5M");
cfg.save();The default message implementation is YamlMessageFormatService (module common).
- Backed by
messages.yml(any depth; keys are flattened using dotted paths). - Values are Adventure MiniMessage templates.
- Unknown MiniMessage tags become placeholders (e.g.
<name>), replaced viaPlaceholders. - If the key
prefixexists, it is automatically prepended to all messages.
messages.yml:
prefix: "<gray>[MyPlugin]</gray> "
example.welcome: "<green>Hello <name>!</green>"Usage:
import de.t14d3.rapunzellib.Rapunzel;
import de.t14d3.rapunzellib.message.Placeholders;
import net.kyori.adventure.text.Component;
Component msg = Rapunzel.context().messages().component(
"example.welcome",
Placeholders.builder().string("name", player.name()).build()
);
player.sendMessage(msg);Wrapper interfaces live in api and provide:
platformId()andhandle()to access the native platform objectextras()(RExtras) as a small per-wrapper key/value store for extensions- Adventure
AudienceviaRAudience(players) RPlayerincludeshasPermission(String)and optional server-side methods (world,location,teleport)
Support varies by platform:
- Paper/Fabric: players, worlds, blocks, location + teleport.
- Velocity: players; worlds/blocks are not available.
Note: RProxyPlayer and RServerPlayer are deprecated in favor of RPlayer and its optional methods.
Scheduler exposes:
run,runAsyncrunLater(Duration, ...)runRepeating(Duration initialDelay, Duration period, ...)
The platform modules provide implementations that map to the underlying platform scheduler.
Messenger is the low-level abstraction used by other network features.
Provided implementations include:
PaperPluginMessenger/VelocityPluginMessenger(plugin messaging overrapunzellib:bridge)RedisPubSubMessenger(Redis Pub/Sub transport, no external Redis client dependency)InMemoryMessenger(single-process/testing)
network/bootstrap/MessengerTransportBootstrap.java can replace the currently registered Messenger with RedisPubSubMessenger based on config (network.transport=redis).
network:
transport: redis
serverName: backend-1 # or env RAPUNZEL_SERVER_NAME
proxyServerName: velocity # or env RAPUNZEL_PROXY_SERVER_NAME
redis:
host: 127.0.0.1
port: 6379
ssl: false
username: ""
password: ""
transportChannel: "rapunzellib:bridge"Call it after platform bootstrap:
import de.t14d3.rapunzellib.Rapunzel;
import de.t14d3.rapunzellib.network.bootstrap.MessengerTransportBootstrap;
var cfg = Rapunzel.context().configs().load(
Rapunzel.context().dataDirectory().resolve("config.yml"),
"config.yml"
);
var result = MessengerTransportBootstrap.bootstrap(cfg, Rapunzel.context().platformId(), Rapunzel.context().logger());
// IMPORTANT: result.closeable() is NOT auto-registered for shutdown.
// Close it yourself, or register it via DefaultRapunzelContext#registerCloseable.NetworkEventBus builds typed JSON messages (Gson) on top of Messenger.
import de.t14d3.rapunzellib.Rapunzel;
import de.t14d3.rapunzellib.network.Messenger;
import de.t14d3.rapunzellib.network.NetworkEventBus;
record Ping(String message) {}
Messenger messenger = Rapunzel.context().services().get(Messenger.class);
NetworkEventBus bus = new NetworkEventBus(messenger);
try (NetworkEventBus.Subscription sub =
bus.register("myplugin:ping", Ping.class, (payload, from) -> {
Rapunzel.context().logger().info("Ping from {}: {}", from, payload.message());
})) {
bus.sendToProxy("myplugin:ping", new Ping("hello"));
}RpcClient is a small request/response helper on top of Messenger (correlation IDs + timeouts + typed results).
import de.t14d3.rapunzellib.Rapunzel;
import de.t14d3.rapunzellib.network.Messenger;
import de.t14d3.rapunzellib.network.rpc.RpcClient;
Messenger messenger = Rapunzel.context().services().get(Messenger.class);
RpcClient rpc = new RpcClient(messenger, Rapunzel.context().scheduler(), Rapunzel.context().logger());
rpc.callProxy("myplugin:service", "ping", null, String.class)
.thenAccept(result -> Rapunzel.context().logger().info("Result: {}", result));NetworkInfoClient (backend) can ask the proxy for:
- backend name (
networkServerName()) - server list (
servers()) - global player list (
players())
On Velocity, VelocityNetworkInfoResponder is the proxy-side handler.
FileSyncEndpoint and FileSyncSpec provide a chunked file sync protocol intended for transports with small message limits (plugin messaging).
Typical use case:
- Authority (often proxy) owns the canonical files and broadcasts invalidations.
- Followers (backend servers) request sync and apply changed/new files.
Plugin messaging has delivery constraints (e.g. requires a player connection). The database-spool module provides:
DbQueuedMessenger- wraps aMessenger, persists allowlisted channels to a shared DB and retries periodicallyNetworkQueueConfig- reads outbox settings fromYamlConfig(network.queue.*)
Platform bootstraps that use plugin-messaging transports (Paper/Fabric/NeoForge/Velocity) call NetworkQueueBootstrap.wrapIfEnabled(...) automatically when network.queue.enabled=true. To persist queued messages, configure network.queue.jdbc (or database.jdbc) to point at a JDBC URL (e.g. SQLite).
Config keys (with defaults):
network.queue.enabled(true)network.queue.allowlist(defaults to["rapunzellib:filesync:invalidate", "db.cache_event"])network.queue.flushPeriodSeconds(2)network.queue.maxBatchSize(200)network.queue.maxAgeSeconds(300)
Minimal setup example:
import de.t14d3.rapunzellib.Rapunzel;
import de.t14d3.rapunzellib.database.SpoolDatabase;
import de.t14d3.rapunzellib.network.Messenger;
import de.t14d3.rapunzellib.network.queue.DbQueuedMessenger;
import de.t14d3.rapunzellib.network.queue.NetworkOutboxMessage;
import de.t14d3.rapunzellib.network.queue.NetworkQueueConfig;
var cfg = Rapunzel.context().configs().load(
Rapunzel.context().dataDirectory().resolve("config.yml"),
"config.yml"
);
NetworkQueueConfig q = NetworkQueueConfig.read(cfg);
if (q.enabled()) {
SpoolDatabase db = SpoolDatabase.open(
"jdbc:sqlite:" + Rapunzel.context().dataDirectory().resolve("outbox.db"),
Rapunzel.context().logger(),
NetworkOutboxMessage.class
);
Messenger base = Rapunzel.context().services().get(Messenger.class);
DbQueuedMessenger queued = new DbQueuedMessenger(
db,
base,
Rapunzel.context().scheduler(),
Rapunzel.context().logger(),
NetworkQueueConfig.defaultOwnerId(),
q.channelAllowlist(),
q.flushPeriod(),
q.maxBatchSize(),
q.maxAge()
);
// Replace the Messenger service with the queued wrapper.
Rapunzel.context().services().register(Messenger.class, queued);
// DbQueuedMessenger only stops its flush task on close; you own the DB lifecycle.
// Close queued/db on shutdown (or register them as closeables if you use DefaultRapunzelContext).
}SpoolDatabase wraps de.t14d3.spool.core.EntityManager and provides:
- a simple synchronized access pattern (
locked,transactional) flush()andflushAsync()- If you use
DbQueuedMessenger, registerNetworkOutboxMessageas an entity. - You need a JDBC driver on the classpath for your chosen
jdbc:URL.
Rapunzel.context()throws until you bootstrap; only one global context is supported.- Plugin messaging transport needs a player connection:
- Paper:
Messenger.isConnected()is false with zero online players; sends are dropped. - Velocity: forwarding to a backend server requires at least one player currently on that backend (queueing can be enabled via
network.queue.*).
- Paper:
- Platform differences are explicit:
- Velocity has no worlds/blocks; some operations throw
UnsupportedOperationException.
- Velocity has no worlds/blocks; some operations throw
YamlMessageFormatServiceturns unknown MiniMessage tags into placeholders; avoid naming placeholders after built-in MiniMessage tags.
The commands module provides small Brigadier helpers intended for reuse across projects.
ListArgumentType<T>- CommandAPI-style delimiter-based list parsing + suggestions (validated viagetList(...))commands-paper/commands-fabric- optional source adapters for Bukkit/Fabric command sources
The events modules provide a small, semantic action-event layer (not a Bukkit event mirror).
Install the platform bridge after bootstrapping the context:
Paper
import de.t14d3.rapunzellib.Rapunzel;
import de.t14d3.rapunzellib.events.GameEvents;
import de.t14d3.rapunzellib.platform.paper.PaperRapunzelBootstrap;
import org.bukkit.plugin.java.JavaPlugin;
public final class MyPlugin extends JavaPlugin {
@Override public void onEnable() {
PaperRapunzelBootstrap.bootstrap(this);
GameEvents.install(this);
}
@Override public void onDisable() {
Rapunzel.shutdown(this);
}
}Then subscribe to cancellable pre-events and/or async snapshots:
import de.t14d3.rapunzellib.events.GameEvents;
import de.t14d3.rapunzellib.events.block.BlockBreakPre;
GameEvents.bus().onPre(BlockBreakPre.class, e -> {
if (!e.player().hasPermission("myplugin.break")) e.deny();
});Plugin id: de.t14d3.rapunzellib.
If you consume it from https://maven.t14d3.de, add the repository to settings.gradle(.kts):
pluginManagement {
repositories {
gradlePluginPortal()
maven("https://maven.t14d3.de/snapshots")
}
}Apply:
plugins {
id("de.t14d3.rapunzellib") version "<version>"
}Provides tasks:
rapunzellibValidateMessages- scans compiled classes for used message keys and compares them tomessages.ymlrapunzellibInitTemplate- generates a starter project templaterapunzellibRunServers- runs Velocity + multiple Paper backends via the included runnerrapunzellibRunPerfServers- runs Velocity + multiple Paper backends with JFR enabled (and MySQL enabled by default)
Notes:
rapunzellibValidateMessagesonly detects string-constant keys and is configurable via therapunzellibextension (messageKeyCallOwners,messageKeyCallMethods, …).rapunzellibRunServers/rapunzellibRunPerfServersdownload jars via PaperMC Fill v3 and write temporary instances torun/server-runner/(MySQL requires Docker).
./gradlew buildOn Windows:
./gradlew.bat build