Skip to content

Developer API v1

Aksel Glyholt edited this page Sep 23, 2026 · 5 revisions

Developer API v1

VelocityLimboHandler 2.0.0+ exposes an in-process Java 21 API for Velocity plugins. It can move players into limbo atomically, pause players or destinations with owner-scoped leases, change a managed player's destination, read immutable queue state, and observe lifecycle events.

The API works only inside the same Velocity proxy process. It is not an HTTP API and does not provide a Paper or backend-server bridge.

Use a published v2.0.0 or newer release tag. If no such release exists yet, API v1 has not been released and the coordinates below will not resolve reliably.

Installation

Use the same VLH release for the installed plugin and compile-time API. The tag includes its leading v. The installed plugin already contains the API classes, so consumers must not shade or relocate the API.

Gradle Kotlin DSL

repositories {
    maven("https://jitpack.io")
}

dependencies {
    compileOnly("com.github.AkselGlyholt.velocity-limbo-handler:velocity-limbo-handler-api:v2.0.0")
}

Maven

<repositories>
  <repository>
    <id>jitpack.io</id>
    <url>https://jitpack.io</url>
  </repository>
</repositories>

<dependencies>
  <dependency>
    <groupId>com.github.AkselGlyholt.velocity-limbo-handler</groupId>
    <artifactId>velocity-limbo-handler-api</artifactId>
    <version>v2.0.0</version>
    <scope>provided</scope>
  </dependency>
</dependencies>

Sources and Javadocs are attached to the API artifact for IDE navigation.

Velocity dependency and initialization

Declare VLH as a required dependency so Velocity loads it before your plugin:

@Plugin(
    id = "my-plugin",
    dependencies = @Dependency(id = "velocity-limbo-handler")
)
public final class MyPlugin {
    private final ProxyServer proxy;
    private VelocityLimboApi api;
    private LimboController limbo;

    @Inject
    public MyPlugin(ProxyServer proxy) {
        this.proxy = proxy;
    }

    @Subscribe
    public void onProxyInitialize(ProxyInitializeEvent event) {
        api = VelocityLimboApi.get(proxy);
        limbo = api.controllerFor(this);
    }
}

Do not call controllerFor(this) in the plugin constructor. Velocity has not registered the plugin instance at that point. controllerFor rejects arbitrary objects and scopes every acquired lease to the consuming plugin's ID.

api.availability() reports:

Value Meaning
STARTING The plugin instance exists, but initialization is incomplete.
READY Reads and mutations are available.
STOPPING Proxy shutdown has begun. Mutations return their not-ready result.

Mutation results must still be checked even after observing READY, because lifecycle state can change between calls.

Enter a player into limbo

Use the player's current backend as their eventual destination:

limbo.enterLimbo(player, EnterRequest.currentServer())
    .thenAccept(result -> {
        if (result.status() != EnterStatus.SUCCESS) {
            logger.warning("Could not enter limbo: " + result.status());
        }
    });

Or name another registered Velocity server:

limbo.enterLimbo(player, EnterRequest.destination("survival"));

Use an initial hold when asynchronous work must finish before the player may join the reconnect queue. The hold is created before movement begins and is removed automatically if movement fails:

EnterRequest request = EnterRequest.currentServer()
    .withInitialHold(new HoldRequest("Loading player profile", Duration.ofSeconds(30)));

limbo.enterLimbo(player, request).thenAccept(result -> {
    if (result.status() != EnterStatus.SUCCESS) {
        logger.warning("Could not enter limbo: " + result.status());
        return;
    }

    result.initialHold().ifPresent(lease ->
        loadProfile(player).whenComplete((profile, failure) -> {
            HoldReleaseStatus released = limbo.releaseHold(lease);
            if (released != HoldReleaseStatus.RELEASED
                    && released != HoldReleaseStatus.NOT_FOUND) {
                logger.warning("Could not release profile hold: " + released);
            }
        })
    );
});

The timeout is a safety net. Release the lease as soon as the work completes.

Entry results

Status Meaning
SUCCESS Velocity accepted the move to limbo.
NOT_READY VLH is unavailable or its configured limbo server is unavailable.
INACTIVE_PLAYER The player is no longer active on this proxy.
ALREADY_MANAGED VLH already manages the player.
CONNECTION_IN_PROGRESS Velocity is already processing another connection.
INVALID_TARGET The destination is absent, blank, or the limbo server itself.
UNKNOWN_SERVER An explicitly named destination is not registered.
CONNECTION_FAILURE Velocity rejected or failed the limbo connection.

The returned CompletionStage has no guaranteed completion executor. Schedule any thread-affine follow-up work explicitly. The stage normally completes with an EnterResult; invalid arguments still throw immediately.

Player holds

A player hold removes a managed player from the reconnect queue. Holds from different plugins are independent, and the player becomes eligible again only after the final hold ends.

HoldResult result = limbo.holdPlayer(
    player.getUniqueId(),
    new HoldRequest("Waiting for matchmaking", Duration.ofSeconds(45))
);

result.lease().ifPresent(lease ->
    matchmaking.whenComplete((match, failure) -> limbo.releaseHold(lease))
);

new HoldRequest(reason) creates an indefinite hold. Prefer a positive duration unless an indefinite lifetime is deliberate, and always retain the returned lease.

Hold results

Status Meaning
ACQUIRED The result contains the new lease.
NOT_READY VLH cannot currently process mutations.
INACTIVE_OR_UNMANAGED_PLAYER A player target is inactive or not managed by VLH.
CONNECTION_IN_PROGRESS VLH already claimed the player for a backend attempt.
INVALID_TARGET The target is blank or is the configured limbo server.

releaseHold accepts the HoldLease itself or its UUID. Use the UUID form to release one of your own holds found through a HoldSnapshot, for example after losing the lease object. It returns a HoldReleaseStatus: RELEASED, NOT_READY, NOT_FOUND, or NOT_OWNER. An expired lease returns NOT_FOUND. A controller cannot release another plugin's lease.

Operations with no payload return a *Status enum. Operations that also return data return a *Result record holding a status plus that data.

To release all holds owned by the controller:

ReleaseAllHoldsResult result = limbo.releaseAllHolds();
if (result.status() == ReleaseAllHoldsResult.Status.COMPLETED) {
    logger.info("Released " + result.releasedCount() + " holds");
}

COMPLETED with a count of zero means there were no active owner leases. NOT_READY means VLH did not process the operation.

When the final player hold ends, VLH rechecks connection issues and queue permissions, then appends the player to the back of the appropriate BYPASS, PRIORITY, or NORMAL tier. Held players do not have a queue position.

Destination holds

A destination hold pauses new connection attempts without moving or reordering queued players:

HoldResult result = limbo.holdServer(
    "survival",
    new HoldRequest("Rolling deployment", Duration.ofMinutes(10))
);

if (result.status() != HoldStatus.ACQUIRED) {
    throw new IllegalStateException("Could not hold survival: " + result.status());
}

HoldLease lease = result.lease().orElseThrow();
deploySurvival().whenComplete((ignored, failure) -> limbo.releaseHold(lease));

Server holds are owner-scoped and stack. New attempts resume only after the final lease is released or expires. A hold does not cancel an already-running connection or move players who are already on the backend.

The server does not need to be registered when the hold is acquired. This deliberately supports dynamic server registration and lets a deployment hold exist before a backend appears. Names are matched case-insensitively. Blank names and the configured limbo server are invalid.

Retarget a managed player

RetargetStatus status = limbo.retargetPlayer(playerId, "minigames");

A waiting player leaves the old queue and joins the back of the permission-derived tier for the new destination. A held player remains outside the queue while their stored destination changes. Retargeting requires a registered destination and is rejected after VLH claims a connection attempt.

Results are SUCCESS, NOT_READY, INACTIVE_OR_UNMANAGED_PLAYER, CONNECTION_IN_PROGRESS, INVALID_TARGET, or UNKNOWN_SERVER.

Immutable snapshots

Read state directly from VelocityLimboApi:

Optional<ManagedPlayerSnapshot> playerState = api.player(playerId);
QueueSnapshot survival = api.queue("survival");
List<QueueSummary> activeQueues = api.queues();
Type Contains
ManagedPlayerSnapshot Phase, destination, optional queue position/tier, issue, player holds, server holds, revision.
QueueSnapshot Ordered players, server holds, and revision for one destination.
QueueSummary Size, counts by tier, held state, and revision.
QueuedPlayerSnapshot Player identity, position, and effective tier.
HoldSnapshot Lease ID, owner, target, reason, and timestamps.

Snapshots are immutable point-in-time values. ManagedPlayerSnapshot, QueueSnapshot, QueueSummary, and ServerHoldChangedEvent carry a revision from one proxy-wide counter that only increases. A higher revision is newer. It does not mean every displayed field changed.

api.player(id) is empty when VLH does not manage the player. api.queue(name) accepts unknown or temporarily unregistered names and returns an empty snapshot unless VLH still has queue or hold state for that name. api.queues() lists destinations that currently have queued players or server holds.

Player phases are ENTERING, ADMITTING, WAITING, HELD, CONNECTING, and CONNECTION_ISSUE. Position and tier are present only while the player is queued.

Lifecycle events

Register API events like normal Velocity events:

@Subscribe
public void onPlayerEnteredLimbo(PlayerEnteredLimboEvent event) {
    HoldResult result = limbo.holdPlayer(
        event.player().getUniqueId(),
        new HoldRequest("Checking session", Duration.ofSeconds(20))
    );

    result.lease().ifPresent(lease -> sessionChecks
        .check(event.player())
        .whenComplete((ignored, failure) -> limbo.releaseHold(lease))
    );
}

API v1 publishes:

Event Timing
PlayerEnteredLimboEvent After arrival is registered, before queue admission. VLH waits for all handlers.
PlayerLimboStateChangedEvent After an observable managed-player transition.
PlayerReconnectAttemptEvent After VLH atomically claims a player for an attempt.
PlayerReconnectResultEvent When that reconnect attempt finishes.
PlayerLeftLimboEvent Immediately before managed state is removed.
ServerHoldChangedEvent After a server hold is acquired, released, or expires.

Events are immutable and non-cancellable. Only PlayerEnteredLimboEvent is an admission barrier; the other events are fire-and-forget observations. If an entry-event handler fails, VLH logs the failure and continues admission using the holds that were committed successfully.

The admission barrier has no timeout. The player stays in ADMITTING until every handler, including async EventTasks, completes. Complete or fail every task you return. For long or open-ended work, acquire a player hold in the handler and return right away, as in the example above.

Handling out-of-order events

No event thread or cross-event delivery order is guaranteed. Use revisions to drop stale events:

private final Map<UUID, Long> lastSeen = new ConcurrentHashMap<>();

@Subscribe
public void onStateChanged(PlayerLimboStateChangedEvent event) {
    ManagedPlayerSnapshot current = event.current();
    long previous = lastSeen.merge(current.playerId(), current.revision(), Math::max);
    if (previous != current.revision()) {
        return; // A newer state for this player was already processed.
    }
    render(current);
}

@Subscribe
public void onLeftLimbo(PlayerLeftLimboEvent event) {
    // A late stale event may re-add one entry; that is bounded and harmless.
    lastSeen.remove(event.snapshot().playerId());
}

Apply the same pattern per server name for ServerHoldChangedEvent. Query api.player(id) or api.queue(name) when you need the exact current queue position.

Ownership and cleanup

  • A controller owns only leases acquired through that controller's plugin ID.
  • Disconnecting a player removes that player's holds and pending entry intent.
  • Server holds survive player disconnects and end only through release, expiry, or proxy shutdown.
  • Authentication uses a separate internal owner and cannot clear a consumer plugin's hold.
  • Call releaseAllHolds() from your plugin's shutdown path if your plugin can be disabled or reloaded independently.
  • Prefer timed holds as a safety net for work involving networks, databases, or other plugins.

Troubleshooting

VelocityLimboApi.get says VLH is not loaded

Install the same or newer VLH plugin version and declare the required Velocity dependency. Do not shade the API into the consumer plugin.

controllerFor rejects the plugin instance

Call it during or after ProxyInitializeEvent and pass the actual Velocity plugin instance.

A hold never ends

Keep the lease and release it on every success and failure path. Prefer a timed HoldRequest. Inspect /vlh status and the proxy log for the owner, target, reason, and lease ID.

A player has no queue position

Players in ENTERING, ADMITTING, HELD, CONNECTING, or CONNECTION_ISSUE may be outside the queue. Read the complete ManagedPlayerSnapshot instead of assuming missing position means missing state.

Compatibility

API v1 starts with VLH 2.0.0 and follows semantic versioning. Compile against the oldest VLH release your plugin supports and run against the same or newer compatible v1 release. Public signatures are checked in CI to catch accidental binary changes.

Do not use the deprecated implementation accessors getPlayerManager() or getReconnectBlocker() for new integrations. The supported public packages begin with com.akselglyholt.velocitylimbohandler.api.

Clone this wiki locally