-
Notifications
You must be signed in to change notification settings - Fork 10
Building a Menu
A surprising number of plugins depend on a random-teleport plugin only to put a destination menu in front of players - a clickable "where do you want to go?" GUI - and then let the RTP engine do the actual teleport. LeafRTP supports that use case as a first-class, safe, platform-neutral API. You do not need to fork the plugin, parse /rtp output, or reimplement safety checks. This page is the addon-developer guide for building a menu or GUI on top of LeafRTP.
!!! tip "You probably do not need to render LeafRTP's own menu" LeafRTP already ships an interactive menu (book-first on Paper/Folia, chat fallback elsewhere; see the player-facing Menu page). This page is for when you want to build your own GUI - a chest inventory, a custom book, a web panel - and have LeafRTP supply the data and perform the teleport.
Legacy random-teleport plugins force integrators to either shell out to /rtp as the player (losing all type-safety and result feedback) or to copy the plugin's coordinate logic into the GUI (which re-runs unsafe synchronous rerolling on the main thread). LeafRTP draws a hard line instead:
- LeafRTP owns safety and validation. Permission gating, cooldowns, cost, and the S-001..S-007 prohibitions (no unsafe block, no claim-protected land, no main-thread chunk I/O, no silently-discarded failures) all live behind the API. A GUI author cannot cause an unsafe teleport through this surface.
- Your addon owns presentation and click handling. Layout, icons, and the open/close/click lifecycle are yours.
- The only mutating call is
teleport(...), which re-validates everything server-side and always completes with a result - never a silent no-op (REQ-RTP-S-004).
The result: your GUI stays trivial, and it inherits LeafRTP's asynchronous pre-generation queue, Folia-native regional scheduling, and spatial memory for free.
Depend on rtp-api (the stable, semver-guaranteed surface) - not rtp-core. A full GUI needs only four RTPAPI calls.
=== "Gradle (Kotlin)"
```kotlin
repositories {
maven("https://jitpack.io")
}
dependencies {
compileOnly("com.github.DailyStruggle.RTP:rtp-api:VERSION")
// metrics-api is only needed if you render a server-health tile:
compileOnly("com.github.DailyStruggle.RTP:metrics-api:VERSION")
}
```
=== "Gradle (Groovy)"
```groovy
repositories {
maven { url 'https://jitpack.io' }
}
dependencies {
compileOnly 'com.github.DailyStruggle.RTP:rtp-api:VERSION'
compileOnly 'com.github.DailyStruggle.RTP:metrics-api:VERSION'
}
```
=== "Maven"
```xml
<repositories>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>com.github.DailyStruggle.RTP</groupId>
<artifactId>rtp-api</artifactId>
<version>VERSION</version>
<scope>provided</scope>
</dependency>
</dependencies>
```
import io.github.dailystruggle.rtp.api.RTPAPI;
import io.github.dailystruggle.rtp.api.RtpTarget;
import io.github.dailystruggle.rtp.api.RtpTargetStatus;
import java.util.List;
import java.util.UUID;
void openPicker(UUID playerId) {
// 1. Ask LeafRTP which destinations THIS player may use (permission gates already applied).
List<RtpTarget> targets = RTPAPI.getAllowedTargets(playerId);
for (RtpTarget target : targets) {
// 2. Decorate each slot with the player's live status (cooldown / cost / availability).
RtpTargetStatus status = RTPAPI.getTargetStatus(playerId, target);
addSlot(target, status); // YOUR rendering: icon, lore, lock/greyed-out, price tag
}
}
void onSlotClicked(UUID playerId, RtpTarget target) {
// 3. Submit a validated teleport intent. LeafRTP re-checks everything server-side.
RTPAPI.teleport(playerId, target).thenAccept(result -> {
if (result.isSuccess()) {
// teleported; result.location() is the landing spot
} else if (result.isQueued()) {
// accepted onto the cross-server wait queue (network mode)
} else {
// result.reason() e.g. ON_COOLDOWN / NO_SAFE_LOCATION / NO_PERMISSION ...
// result.message() is a human-readable explanation. Never silent (S-004).
}
});
}That is the whole integration. The teleport future always completes with an RTPResult, so your GUI can always give the player feedback.
!!! warning "rtp-api entry points throw before core loads"
Every RTPAPI method throws IllegalStateException if called before rtp-core has finished loading (REQ-RTP-S-006) - it never returns null or silently no-ops. Call these from inside your addon's onLoad() (where core is guaranteed loaded) or in response to a player action, not from a static initializer.
Returns an immutable List<RtpTarget> of the destinations the player is actually permitted to use, with the same permission gates the /rtp command applies already resolved. The first element is always RtpTarget.defaultRegion() (a bare /rtp is always offered), followed by any named regions/worlds the player passes the permission check for. Render the list verbatim - one slot per entry.
An immutable selector that hides all rtp-core region internals. Build one with a static factory:
| Factory | Resolves to |
|---|---|
RtpTarget.defaultRegion() |
the server default region (a bare /rtp) |
RtpTarget.region("spawnlands") |
a region by its configured name |
RtpTarget.world("world_nether") |
the region configured for a named world |
RtpTarget.network("backend-b", "wilds") |
a region on a peer backend (network mode); routed via the cross-server wait queue |
Returns a point-in-time RtpTargetStatus for decorating one button. It is read-only - it never teleports or charges the player.
| Accessor | Use for |
|---|---|
availability() |
enum: READY / ON_COOLDOWN / NO_PERMISSION / NO_FUNDS / DISABLED / UNKNOWN
|
isReady() |
convenience boolean for "clickable right now" |
remainingCooldownMillis() |
grey-out timer / countdown lore |
cost() |
price tag lore |
iconBlock() / environment() / label()
|
optional display hints (may be null) |
!!! note "UNKNOWN is a real value, plan for it"
The Availability enum may grow over time, and a null delegate result degrades to UNKNOWN rather than throwing. Always have a sensible fallback rendering for an availability you do not recognise.
CompletableFuture<RTPResult> teleport(UUID player, RtpTarget target);
boolean cancel(UUID player); // cancel a pending/in-flight teleport
boolean isWarmingUp(UUID player); // a teleport requested but not yet completed?
int queueDepth(RTPWorld<?> world); // pre-verified ready locations for a worldPermission, cooldown, cost, and every S-00x safety check are enforced inside this call, regardless of what getAllowedTargets / getTargetStatus returned. Treat those reads as display hints and teleport(...) as the authority.
The teleport future always completes with an RTPResult so you can always inform the player (S-004 - no silent failures):
RTPAPI.teleport(playerId, RtpTarget.defaultRegion()).thenAccept(result -> {
switch (result.reason()) {
case SUCCESS -> /* result.location() is non-null */;
case QUEUED -> /* accepted onto the cross-server wait queue */;
case ON_COOLDOWN, COOLDOWN -> /* show remaining cooldown */;
case NO_SAFE_LOCATION -> /* pipeline found nothing safe */;
case ALREADY_TELEPORTING, LOCKED, RELOADING, PLAYER_OFFLINE,
INVALID_TARGET, CANCELLED, ERROR -> /* result.message() explains */;
default -> /* forward-compatible fallback */;
}
});isSuccess() and isQueued() are both non-failure outcomes (a queued request will complete on a remote backend after transfer). On success location() is non-null; on failure it is null and message() carries a human-readable explanation.
If your menu shows live server health, read a sampled snapshot - it is produced by the metrics sampler, never computed on your thread, so it adds no main-thread cost and no chunk I/O:
import io.github.dailystruggle.metrics.api.MetricsSnapshot;
MetricsSnapshot snap = RTPAPI.getMetricsSnapshot(); // TPS, MSPT, player count, heap, ...
// On Folia, per-region detail:
RTPAPI.getRegionSamples().forEach(sample -> /* region-scoped tile */);Unsampled fields report MetricsSnapshot.UNSAMPLED; check before displaying.
addons/RTP_GuiAddon is a complete, drop-in destination picker built only on the stable rtp-api surface above (it deliberately does not link rtp-core, proving the public surface is sufficient for a full GUI). It is structured like the plugin itself - a platform-neutral core plus thin per-platform renderers:
| Module | Platform | Responsibility |
|---|---|---|
rtp-gui-common |
none | the menu model, guimenu.yml config, the render seam, the teleport submit. No org.bukkit.*. |
rtp-gui-bukkit |
Bukkit/Paper/Folia | chest-inventory renderer + click listener |
rtp-gui-fabric |
Fabric (MC 26.x) | server-side container (ChestMenu) screen renderer |
rtp-gui-neoforge |
NeoForge (MC 26.x) | server-side container screen renderer |
rtp-gui |
none (assembly) | the single multi-loader distributable jar |
It demonstrates the security boundary precisely: one slot per getAllowedTargets(UUID) target, each decorated via getTargetStatus(UUID, RtpTarget), a health tile from getMetricsSnapshot(), and click -> teleport(UUID, RtpTarget). The Bukkit renderer cancels every click on the read-only chest (anti-dupe) and identifies its own inventory by a custom InventoryHolder, never by a spoofable title.
!!! tip "Read it before writing your own"
The reference addon is the fastest way to see the whole pattern end-to-end, and it doubles as a working template. Build it from the repo root with .\gradlew :addons:RTP_GuiAddon:rtp-gui:build.
If you want a bare /rtp (no arguments) to open your GUI instead of teleporting, bind the root-action hook. Sub-commands (/rtp region=..., /rtp admin, ...) are never affected:
RTPAPI.hooks().rootAction().bind((uuid, feedback) -> {
openMyMenu(uuid);
return true; // true = handled (classic teleport suppressed); false = defer to classic /rtp
});This is the seam RTP_GuiAddon uses so a bare /rtp opens the picker. See Hooks and verifiers for the full hook catalog.
Register your own /rtpgui-style command in your plugin/mod as usual and call openMyMenu(...) from it - LeafRTP imposes nothing here.
LeafRTP's internal menu is built on a platform-neutral SPI in rtp-api (MenuModel, MenuPage, MenuLine, MenuFragment, MenuAction, MenuOpenRequest, and MenuRenderer). If you want LeafRTP to drive your renderer with its menu content (rather than building your own model), implement MenuRenderer:
public interface MenuRenderer {
void render(UUID playerId, MenuModel model); // throws IllegalStateException pre-core-load (S-006)
}An inventory GUI is then "just another MenuRenderer" - LeafRTP does not write it for you. Most integrators do not need this; the four RTPAPI calls above are simpler and decouple you from the menu model's shape. Reach for MenuRenderer only when you specifically want to re-skin LeafRTP's own pages.
- Depend on
rtp-api(compileOnly/provided), notrtp-core, unless you need an unstable introspection hook. - Call
RTPAPImethods only after core is loaded (insideonLoad()or in response to player actions) - they throw pre-load (S-006). - Treat
getAllowedTargets/getTargetStatusas display hints;teleport(...)is the authority. - Always handle the
RTPResult(includingUNKNOWN/unrecognised reasons) so the player gets feedback (S-004). - Do your own anti-dupe on click handling (cancel clicks on a read-only inventory; identify your inventory by holder, not title).
- Never load chunks or run blocking work on the main thread - the teleport is async and LeafRTP handles it.
- Addon development - the developer landing page and happy path.
- Hooks and verifiers - the root-action hook and the full behavior-modification catalog.
-
Example addon - the
RTPAddonlifecycle and config seam. - Menu - LeafRTP's own player-facing menu (admin/player perspective).
-
API - the broader
rtp-apireference.