Building a Plugin
Everything a new feature plugin needs to integrate correctly with the framework — assumes you're hooking into the existing system, not modifying it.
Project setup
- New repo,
RK<Name>convention. If it needs its own packets, a separate repo for those too (<Name>Packets) — packets are per-plugin, not added toRKPackets. .imlneeds whichever global IntelliJ libraries you reference:PluginAPI,RKPackets,RKClient,RKUpdatePackets,RKUpdater,gson-2.10.1, plus your own packets library. Referencing theRKClienttype pulls inRKUpdatePackets/RKUpdatertransitively — Java resolves its full type hierarchy at compile time even if you never call those methods.
name: RK<Name>
main: rk.<name>.<Name>
version: 1.0.0
author: <your name>
description: "..."
license: MIT
loadorder: 5
loadorder must be higher than RKClient's 0 — this is load-bearing, not cosmetic. RKClient builds its packet registry and event pipeline in onEnable(); if your plugin loads first, both are still null when you try to register.Manifest is only needed if you have your own dependency jar: Class-Path: Libs/<Name>Packets.jar — nothing else. gson, RKPackets, PluginAPI resolve for free at runtime via Rising World's shared classloader once RKClient/RKUpdater have loaded them.
Web/doc.html (see deployment) should list any private Libs/ jar you ship (e.g. your own packets jar) and which of RKClient's SharedLibs you actually require — see any existing plugin's page for the expected format.Talking to the backend (packets)
Packets live in their own repo (<Name>Packets), each class extends rk.packets.Packet — a serialization contract, so both sides always read from one canonical deployed file.
Client — receive:
rkClient.registerPacketHandler(MyResponsePacket.class, packet -> {
MyResponsePacket response = (MyResponsePacket) packet;
});
Client — send:
rkClient.getBackendClient().sendPacket(new MyRequestPacket(...));
Backend — respond, via BackendModule:
public class MyModule implements BackendModule {
public void registerHandlers(PacketRegistry registry) {
registry.register(MyRequestPacket.class, (packet, clientHandler) -> {
MyRequestPacket request = (MyRequestPacket) packet;
clientHandler.sendPacket(new MyResponsePacket(...));
});
}
}
Then one line in RKBackend.start(): List<BackendModule> modules = List.of(new MyModule(...)); — nothing else needs an edit.
Backend database access, same shape as the existing TransferManager/FreetravelManager:
public class MyManager {
private final Connection conn;
public MyManager(Database database) throws SQLException {
this.conn = database.getConnection();
try (Statement s = conn.createStatement()) {
s.execute("CREATE TABLE IF NOT EXISTS my_table (...)");
}
}
}
No generic table-registration API — direct constructor injection is the whole pattern.
Reacting to player lifecycle (events)
Don't listen to raw PlayerConnectEvent/PlayerDisconnectEvent directly. RKClient already owns those. If your plugin independently listens too, you can't tell whether a player will actually stay, and you risk two plugins fighting over the same cancelable event with no idea about each other — this happened for real in a prior project (a Taming plugin cancelling NPCDeathEvent to protect tamed animals, a separate Butcher plugin listening to the same event for kill resources — result, an infinite-hide duplication exploit, neither plugin wrong in isolation).
Register for RKClient's own resolved callback instead:
rkClient.registerEventHandler(PlayerVerifiedEvent.class, event -> {
PlayerVerifiedEvent verified = (PlayerVerifiedEvent) event;
Player player = verified.getPlayer();
// fires only once this player has passed the wrong-server check
});
rkClient.registerEventHandler(PlayerLeftEvent.class, event -> {
PlayerLeftEvent left = (PlayerLeftEvent) event;
});
Publish your own events for other plugins to react to — same mechanism, reversed:
public class MyBalanceChangedEvent implements RKClientEvent {
private final Player player;
private final long newBalance;
}
// wherever your state actually changes:
rkClient.getEventPipeline().fireEvent(new MyBalanceChangedEvent(player, newBalance));
Note fireEvent lives on EventPipeline, not on RKClient directly — rkClient.getEventPipeline().fireEvent(...). A UI plugin subscribes with registerEventHandler(MyBalanceChangedEvent.class, ...), no compile-time dependency beyond your event class, no network round trip since this is purely in-process.
Anything not cancelable/conflict-prone (PlayerEnterSectorEvent, PlayerCommandEvent, etc.) is fine to listen to directly via Rising World's normal event system.
Settings
Loading, with additive defaults:
Path settingsDir = Path.of(getPath(), "Settings");
JsonObject merged = SettingsMerger.loadAndMerge(MyPlugin.class,
settingsDir.resolve("MySettings.json"), "Settings/MySettings.json");
Pass your own class, not RKClient.class — resourceAnchor resolves the bundled default relative to whichever class you pass. A key present in the default but missing locally gets inserted; anything already customized is never touched. Arrays are atomic — use an object keyed by stable ID if new default entries need to reach existing installs automatically.
Bundle the default at src/Settings/MySettings.json — physically under src/, IntelliJ packages it into the jar automatically.
Renaming a file or key later, without losing a customized value:
public final class MySettingsUpdates {
private static final List<String> FILES = List.of("MySettings.json");
private static final List<SettingsMigrator.FileRename> FILE_RENAMES = List.of(
// new SettingsMigrator.FileRename("Old.json", "New.json")
);
private static final List<SettingsMigrator.Migration> KEY_MIGRATIONS = List.of(
// new SettingsMigrator.KeyRename("New.json:oldKey", "New.json:newKey")
);
public static void apply(Path settingsDir) throws IOException {
SettingsMigrator.run(settingsDir, FILES, FILE_RENAMES, KEY_MIGRATIONS);
}
}
Deployment layout
Client side, per plugin:
Plugins/<Name>/
├── <Name>.jar
├── Libs/<Name>Packets.jar (Libs, NOT SharedLibs)
└── Settings/*.json (auto-created on first run)
SharedLibs is reserved for RKClient's own folder only. Backend side, if you have packets: Updates/Plugins/<Name>/Libs/<Name>Packets.jar — the single canonical file, RKServer's manifest should redirect to it rather than keeping a separately-synced local copy.
Your docs page is separate from all of the above and never goes near a game server: author Web/doc.html in your own repo (not under src/ — it's not bundled into your jar), then copy it to Web/Plugins/<Name>/doc.html on the backend, the same motion as deploying your jar. It's picked up automatically at /documentation/plugins/<Name> and listed on the site's index and every page's sidebar — no code change anywhere. Editing it later never needs a recompile or restart, just re-copy the file.
Participating in the updater lifecycle (recommended)
public class MyPlugin extends Plugin implements Listener, UpdateAware {
public void onEnable() {
Plugin updaterPlugin = getPluginByName("RKUpdater");
if (updaterPlugin instanceof RKUpdater updater) {
updater.registerPlugin(this);
} else {
onUpdatesFinished(); // dev/test fallback
}
}
public void onUpdatesFinished() {
initializePlugin(); // real init here, not onEnable()
}
private void initializePlugin() {
// register packet handlers, event handlers, load settings ...
reportReady();
}
}
Gets you auto-install, auto-update via hash comparison with no manual jar copying, and your plugin's readiness genuinely gates player connections alongside everyone else's.
Real gotchas already hit
- Manifest
Class-Pathline wrapping — a continuation line's leading space is a strip-only marker, not a separator. The real separating space goes at the end of the previous line. Get it wrong and jar entries silently merge into one unresolvable path. - Stale-jar deployment sequencing — manually deploying a plugin that depends on another plugin's in-flight update, while the dependency's active jar is still old, fails Rising World's whole-batch class resolution and disables every plugin, RKUpdater included. Full updater-lifecycle participation avoids this entirely.
- Binary compatibility across a package move — even a pure package reorganization with no logic change requires every dependent plugin to recompile. Java bakes exact types into compiled call sites.
- Test a real edge case, not just connect/disconnect — some logic (border-sector detection, periodic rechecks) only runs once a player actually triggers it.
Design principles
- No formal core API interface layer. Code directly against the concrete
RKClient/RKBackendclasses — an interface only pays for itself with a second real implementation, which won't exist here. - Event ownership: one owner per cancelable event, everyone else gets a resolved callback. Packets don't have this problem — each type only ever has one true handler by construction.
- No dynamic plugin/module discovery on the backend. The
BackendModulelist is a plain, explicit list you edit by hand. - Never build/compile these projects for the maintainer — they compile and deploy everything themselves.