Skip to content

What a plugin can do

The reads are covered in Writing a plugin. These are the calls that change something, and one that changes nothing in the cluster but tells everybody else what this server is up to.

Moving a player

api.connect(player.getUniqueId(), Target.group("lobby"))
   .thenAccept(r -> getLogger().info("ordered: " + r.ordered() + " -> " + r.target()))
   .exceptionally(e -> { getLogger().warning("could not move them: " + e); return null; });

Target.server(...) says exactly where. Target.group(...) says "wherever that group has room" and lets the operator pick, which it can do better than you can: it compares every backend's occupancy without racing the mirror.

It returns a CompletionStage on both platforms, including the proxy where it need not. Following the platform would make this synchronous on one side and not the other, and moving a plugin between them would be a rewrite rather than a recompile.

ordered is not moved. The proxy that carries the move does not wait on Velocity's own future (blocking a network callback on a round trip to a backend is a cost the agent cannot pay), so nothing in this system can tell you the player arrived. If you need to know, read player(uuid) a moment later.

A failure is ordinary. A player who logged out between your call and the operator reading it fails with NOT_FOUND; so does a target that is not routable yet.

Changing the fleet

Five calls write, and all are round trips through the operator on either platform: nothing local can answer them. The first three are about the servers a group already has and how many it tries for: retire, boost and stopBoosts. The last two, further down, are about one player's private server.

retire(server) asks one server to stop taking joins and empty out. It is not a stop. Nobody is moved and nobody is kicked; the players on it finish in their own time, and the server goes away once it is empty. Asking for a server that is already retiring fails, on purpose: the operator distinguishes "you retired it" from "somebody had already asked", and a caller that wants to treat the second as success can do that far more safely than one that was never told.

unretire(server) takes a retirement back while the server is still retiring: it takes joins again and is held, so nothing automatic removes it any more. It fails for a server that is already stopping or is not retiring.

retire also takes a proxy's name: the proxy is replaced, takes no new connections and stops once empty.

status() and status(target) ask the operator how the network is doing and answer with a NetworkStatus: CPU and memory per group against requests and limits (ResourceUsage, which says how many pods it measured), each group's lowest TPS (GroupStatus), and for a group or a single server or proxy its members with TPS, MSPT, age and markers (InstanceStatus). TPS and MSPT are OptionalDouble, empty where nothing was reported. metricsAvailable() is false on a cluster without a metrics API. A target is looked up as a server group, a proxy group, a server, then a proxy; an unknown one fails.

ServerInfo.node(), ProxyInfo.node() and InstanceStatus.node() name the Kubernetes node the pod runs on, empty while it is not scheduled. ServerInfo.unschedulable() says the scheduler has found no node for a server's pod; Waiting for room says what to do with that.

boost(group, replicas, forHowLong) adds capacity for a while, as a ScaleBoost object rather than as an edit to the group. Pass null for the operator's default of an hour.

It adds to what a group tries for and never to what it may reach. The group's maxReplicas still binds, and a request for more than the ceiling leaves is refused, not trimmed, so you get what you asked for or are told why not. The operator also bounds how long you may ask for. Boosts add rather than replace: two calls make two boosts, which is what makes "somebody else already boosted this" a non-event rather than a race.

stopBoosts(group) ends all of them and reports how many there were. Zero is an ordinary answer, not a failure.

None of those can change what a group is. A boost expires; a group that needs to be permanently bigger needs its ServerGroup edited by a person, and this API deliberately cannot do that: the operator holds no write on servergroups at all. Scaling and boosts is the same machinery seen from the cluster side.

startServer(group, key) asks for one player's private server: a member of a group of type OnDemand, named <group>-<key>, whose world is a claim of its own. It answers a StartedServer: the name, and whether it was already there (a success, not a refusal). The stage completing says the server was asked for and nothing about its being ready. startServer(group, key, options) also chooses that member's memory, CPU and heap through a StartOptions, within the bounds its group sets in spec.memberResources; StartedServer.resources() and, on a proxy, ServerInfo.resources() say what a member runs with (choosing resources).

stopServer(server) deletes a server. The players on it are moved through the proxies, the pod goes, and the world stays on its claim, where the next startServer of the same key finds it. It is the only call here that deletes a server, and what bounds it is the key check: a server that carries no key (every one in an Ephemeral or Persistent group) is refused, so a mistyped name cannot take down a lobby. Who may call is who may install a plugin in the namespace, as for every call on this page; the group's maxInstances bounds how many there can be.

deleteServer(group, key) deletes a private server for good: it stops the member if it runs, as stopServer does, and deletes its world. It takes the group and the key because a stopped member is nothing but its world. It reaches on-demand members only, and the chart's admission policy holds the operator to exactly those claims.

restorePoints(group, key) and restoreWorld(group, key, generation) take a private server's world back to an older generation, for a group whose worlds live in the object store and keep a history (worlds in an object store). The list is newest first, and its first entry is the world as it stands. settling() says the world may still change, because the member runs, is stopping, or its final upload has not finished: show it as being saved and hold restores back until a later listing says false. listRestorePoints returns the list alone. A restore needs the member stopped and writes the old content as a new generation, so it can itself be undone. Who may restore is your plugin's decision; the operator asks nobody. Right after a stop the world's final upload may still run, and the restore answers UNAVAILABLE until it is done, so ask again a few seconds later. It answers the same while another restore of that world runs, or while a restore whose operator died still holds the world, which lasts until its lease goes stale after 10 minutes. Both calls can take up to a minute to answer, so they time out after 75 seconds instead of the usual ten. After a timeout you cannot tell whether the restore happened. Asking again is safe: while the member has not run since, a repeated restore answers with the generation the first one made.

Each fails with a reason. startServer says REFUSED, NOT_FOUND, or UNAVAILABLE for a request that succeeds once a server that is stopping has gone. stopServer says REFUSED or NOT_FOUND, and UNAVAILABLE only when the operator could not act at all just now. restorePoints and restoreWorld say NOT_FOUND, REFUSED, or UNAVAILABLE, the last also when the operator could not read the bucket just now. The private servers guide has the table.

Telling one run of a server from the next

ServerInfo.incarnation() is an opaque token that changes whenever the process behind a name is replaced, and never otherwise. Compare it; never parse it.

The name cannot answer that on its own, and for one kind of server it never will: an ephemeral server is named afresh every time, but a persistent one keeps its name across every restart, because that name is the identity of its world. Anything that remembers a server and later asks whether this is still the one it meant (a rejoin, a queue, a scoreboard that outlives a reconnect) compares this and not the name.

It is empty for a server whose pod the operator has not seen yet, which is a server nobody is being sent to.

Closing this server to new players

acceptJoins(false) stops the proxies sending anybody new here. acceptJoins(true) undoes it.

It is not retire. Retiring says the server is finished: it stops taking joins, empties out, and is taken down once it is empty. This says only the first of those, it says it for as long as you like, and you can take it back. A round that has started is not a server that is going away, and asking for the one when you mean the other reads as a decommissioning to everybody who looks at it afterwards.

Nobody is moved. The players already here go on playing until they leave on their own.

The phase does not change. A closed server is still READY, because the phase is the operator's account of a server's lifecycle and shutting a door is not a lifecycle event. What changes is ServerInfo.registered(), the field a caller choosing where to send somebody already reads.

Your group notices: a closed server's empty seats stop counting as the group's free capacity, so a group sized by spare slots builds a replacement rather than sitting at its floor while every server in it has shut its door.

Refused on a proxy, which is not in anybody's routing table but is the routing table. And like announce, it survives a reconnection without being called again: the agent restates the last door state on every new session, because the operator's default for a session it has never seen is open.

Saying how many seats count

playableSlots(n) tells the operator how many of this server's seats count as capacity, for as long as you do not change it. playableSlots(0) hands the decision back to the group's spec.playableSlots. When the group sets enforcePlayableSlots, the same number is also the door: the server refuses logins past it, except for players with spawnery.join.full.<group> (see scaling).

Spawnery.api().playableSlots(12);

It asks nothing. The value rides on the agent's periodic report, so there is no stage to wait for and nothing to fail on the network; it reaches the operator within one report interval and is restated on every new session.

Nobody is turned away by it, unless the group sets enforcePlayableSlots (below). Players beyond the playable seats are still admitted up to the server's limit. The server then counts as full: its group builds another one, and a connect to the group prefers a server with room.

A figure above the server's slots counts as its slots. A negative one throws IllegalArgumentException; on a proxy the call throws UnsupportedOperationException. ServerInfo.playableSlots() reads back what the operator resolved for any server.

Saying what this server is doing

announce(state, attributes) publishes a short description of this server that every other agent in the network reads back as ServerInfo.state() and ServerInfo.attributes().

The cloud carries it and never reads it. Nothing the operator decides looks at a word of it: not where a player is sent, not when a server is replaced, not how a group is sized. That is what makes it safe to put anything in, and useless to put an instruction in.

It is not ServerInfo.phase(). The phase is the operator's account of a server's lifecycle and no plugin can write it. This is the server's own account of itself, and the two are meant to disagree: a server is READY from the moment it can take players until it stops, and what is happening inside that window is a question only the thing running there can answer. /cloud info prints both, and prints yours after the word says so that an admin reading a line where they disagree can tell which is which.

Each call replaces the last one whole. Attributes are not merged: publish the whole description each time, which is also the only way an attribute can be taken back. An empty announcement clears the description, and that is a description like any other: a game that finished and said so does not come back after a reconnect still claiming to be running.

The operator refuses rather than trims: at most 64 characters of state, at most 16 attributes, at most 64 and 256 characters of name and value. It refuses a call from a proxy too, which has no per-instance record in the network picture for a description to appear in. Each refusal says which.

You do not have to re-announce after a reconnection. The agent holds the last description you published and re-publishes it on every new session, so an operator that restarts does not leave a running game described as nothing.

What a person wrote down about a group

Group.attributes() is the counterpart, and the difference is who writes it. A server describes what it is doing right now; a group's attributes are written by a person in the group's own definition (spec.attributes on a ServerGroup or a ProxyGroup) and change when somebody edits that file. Read them for what no server could tell you: which permission a group is behind, which of several games it runs, whose it is.

The operator carries those too and reads none of them. They stop at the same bounds, enforced by the API server rather than by the operator: sixteen entries, names of at most 64 characters, values of at most 256.

What a group is called

Group.displayName() is the one thing about a group that is written for a person rather than for a plugin: what to print on a scoreboard, in a chat message, as a key somebody will read later. A group's name() is a DNS label (lowercase, no spaces), and a name people say out loud rarely is, so bingo-team carries Bingo-Team here. It comes from spec.displayName on the ServerGroup or ProxyGroup, at most 64 characters.

It is never empty. A group nobody has named is displayed by its own name, and that substitution is the agent's, so you do not check which of the two you got.

Hearing what happened

events() hands back an EventBus, the same one every time, so a plugin may hold it. subscribe(listener) returns an AutoCloseable. Close it on disable, or the listener outlives your plugin in a classloader the platform is trying to unload, which is the ordinary way a reload turns into a memory leak. Closing twice is fine.

try (AutoCloseable events = Spawnery.get().events().subscribe(e -> {
        if (e.warning()) {
            getLogger().warning(e.subject() + ": " + e.message());
        }
})) {
    // ...
}

A feed and not a ledger. An agent that was disconnected missed what happened while it was gone, and nothing replays it: the network picture it re-syncs on reconnect is the correction, and a better one than a replay would be: it says what is true now rather than what was true in an order nobody was watching. If you need a ledger, watch the objects.

You get the facts, one per transition. What a player sees in chat is a collapsed summary (ten Ready transitions become one line), and you get the ten. The message is the operator's own sentence, the same one kubectl get events shows, so logging it puts you in agreement with whoever is reading the cluster.

On a backend you get no events about on-demand groups or their members. Those reach proxies only, as the network picture does.

kind is a string and not an enum. The operator's vocabulary gains values, and an agent older than one has to show it rather than fail to parse the message it arrived in. Match on the ones you know; pass the rest through.

Your listener runs on a network callback thread. Do not block it and do not touch the world from it. Hand the work to your platform's scheduler. A listener that throws is dropped from the next dispatch rather than taking the session down with it, but it is still your bug and nothing tells you twice.

What is not here yet

Nothing from the design remains unbuilt at this layer. Methods will be added to SpawneryApi, never changed: plugins consume this interface and do not implement it, so an addition breaks no caller.