The Velocity plugin that lets other plugins act on a player who is connected to a different proxy.
A Velocity proxy only knows its own players. Ask it for dahendriik while they are on the other proxy and you get nothing — which is why /msg, party invites and party warps used to answer "not online" the moment a second proxy existed. plugin-proxy is the piece that answers for the whole network.
ProxyService, published into the ProxyServiceRegistry:
| method | local player | player on another proxy |
|---|---|---|
resolvePlayerId(name) |
Velocity's player list | PlayerSessionQuery (service-player) |
resolvePlayerName(id) |
Velocity's player list | PlayerSessionQuery |
isOnline(id) |
Velocity's player list | PlayerSessionQuery |
getPresence(id) |
current server | session's proxy + server |
sendToPlayer(id, msg) |
player.sendMessage |
publish proxy.system.<id> |
transferPlayer(id, server) |
connection request | publish proxy.transfer.<id> |
suggestPlayerNames(prefix) |
filtered in memory | prefix search, capped, 2s cache |
Local first, always — a player on this proxy is already in memory and costs nothing to find.
plugin-chat / plugin-social consumers: only ever call ProxyService
│
▼
ProxyService ← registered by plugin-proxy (this repo)
│
├── lookups ──► PlayerSessionQuery ← registered by plugin-player,
│ answered by service-player (Postgres, TTL)
│
└── delivery ──► NATS: proxy.system.<uuid>, proxy.transfer.<uuid>
↑ every proxy subscribes for its own players
Nobody registers PlayerSessionQuery → lookups return null and everything degrades to local-only, silently. That is exactly the state this repo was in before plugin-player shipped: the fallback existed and nothing filled it.
Why service-player and not a registry in the proxies? Presence already lives there — TryPlayerLogin is on the login path, with heartbeats and a TTL. A second store in the proxies would be a second source of truth with its own expiry, and two of those drift. An earlier attempt (plugin-chat's own chat.players.join/leave map) also had no memory: core NATS does not replay, so a proxy only ever learned about players who joined while it was running.
// build.gradle.kts — compileOnly, NEVER shaded (see ProxyServiceRegistry's KDoc)
compileOnly("gg.grounds:plugin-proxy-api:0.1.0")@Plugin(
id = "plugin-yours",
dependencies = [Dependency(id = "plugin-proxy")], // orders init; optional = true to degrade
)
class YourPlugin {
@Subscribe
fun onInit(event: ProxyInitializeEvent) {
val proxyService = ProxyServiceRegistry.get(ProxyService::class.java)
val id = proxyService?.resolvePlayerId("dahendriik") // null → plugin-proxy absent
}
}Shading plugin-proxy-api gives your plugin its own copy of the registry class — a different map, which nobody writes into. Every lookup then returns null and cross-proxy features degrade to local-only without a single error in the log. compileOnly is not a style preference here.
suggestPlayerNames(prefix, limit) is a prefix search with a cap, and there is deliberately no "give me every online player". Velocity fires tab-complete on every keystroke: at 10k players online, a roster dump is a ~200 KB response issued thousands of times a second, with a table scan behind each one. So: local matches from memory, the network only once the prefix is ≥ 2 characters, answers cached 2s per prefix, result capped (default 20).
/motd changes what the whole network shows in the server list. The MOTD is stored in
service-config as one document (app=velocity, namespace=motd, key=active), so it survives
restarts, applies to every region including ones that come up later, and is the same document a
dashboard will edit later — nothing here is only true in-game.
/motd show what is being served, with the placeholders resolved
/motd set <MiniMessage> change it; <newline> starts the second line
/motd import <link|id> take a design from motd.gg
/motd maxplayers <n|clear> the number right of the slash
/motd preview [MiniMessage] render it to yourself, change nothing
/motd reset remove it; Velocity's own MOTD is served again
Gated on grounds.motd.manage, which is not one of the roles forge grants by default — grant it
to a custom role, the same way in-game administration is granted.
Resolved per ping, so one stored MOTD reads differently depending on which region answered.
| token | value |
|---|---|
{{region}} |
REGION — the datacentre (nl-ams1) |
{{localzone}}, {{continent}} |
CONTINENT — eu / na |
{{players}} |
the network-wide player count this ping reports |
{{max}} |
the player cap this ping reports |
A known token with no value renders as nothing; an unknown one is left standing, so a typo shows up in the server list instead of disappearing.
motd.gg is a MOTD editor with a live server-list preview. Design there, then
/motd import https://motd.gg/<id> — the id, the link and the .json all work. Only the read half
of motd.gg's own plugin is mirrored: nothing is uploaded, so importing does not publish this
network's MOTD anywhere. A server icon in the imported document is reported and not applied —
the network icon is brand, served from the CDN.
Every proxy polls GetSnapshot-style reads every MOTD_REFRESH_SECONDS; service-config's own
contract makes the read the source of truth and its NATS event only a latency hint, so subscribing
as well would buy seconds in exchange for a second way for the two to disagree. Whoever runs
/motd set sees it immediately on their own proxy; the rest follow within one interval. A refresh
that fails keeps the previous MOTD rather than emptying every region's server-list entry at once.
Writes go to ConfigAdminService, which service-config restricts to admin service accounts and to
writers explicitly allowed for the app — see GROUNDS_CONFIG_WRITERS there. A proxy that is not
allowed can still show the MOTD; /motd set then reports the refusal instead of failing silently.
The footer is one localized status line, for example Lobby 2 • 42 players online • 17 ms.
The server number is its deterministic position among the currently registered servers of the
same kind, not a pod identifier. All proxies with the same discovery inventory show the same
numbers. The online count uses the existing fresh network-wide broadcast; an unknown count is
shown as a dash rather than a misleading proxy-local total.
/proxydebug shows the actual backend and proxy identifiers to players with
grounds.proxy.debug. The node is declared in the plugin's permission catalog for autocomplete.
/proxy lists the proxies that hold players; /proxy <id> (full pod name or its last segment)
moves you to that proxy, for players with grounds.proxy.switch. No address reaches one proxy, so
the player is transferred back through the region's own REGIONS address with the target in a
cookie, and each proxy they land on either keeps them or sends them round again, at most eight
times. Java 1.20.5+ only, and every miss costs one lobby load.
Minestom's permissions module supplies the initial rank-formatted tab entry before ADD_PLAYER is
sent. While a remote display lookup warms up, the proxy retains that supplied badge for at most
five minutes, covering the bounded queue of 128 requests with two-second HTTP timeouts. A resolved
role replaces it immediately; a missing local rank clears immediately. A completely unresolved or
empty remote result can retain the backend display until that limit, then it is cleared. This is
display metadata only and never supplies permission grants. Active expired entries cannot be
re-armed by another viewer during capacity cleanup.
The tab list uses compact rank labels (ADMIN, DEV, MOD, USER, SUP, BUILD)
and the resource pack's grounds:tab_labels font. Its five-pixel letters leave one
transparent pixel above and below the ink. Bedrock players get a bedrock-block
icon immediately before their name (U+E004 in grounds:tab). Deploy the matching
resource pack before the proxy update.
Floodgate identifies local Bedrock sessions, including linked Java accounts. Every
five seconds, proxies exchange edition snapshots on proxy.platform.<GROUNDS_ENVIRONMENT>;
remote snapshots expire after 30 seconds. All proxies in an environment must share
GROUNDS_ENVIRONMENT, have distinct PROXY_ID values, and be allowed to publish and
subscribe to that subject when NATS subject permissions are configured. Without a
snapshot, unlinked Floodgate UUIDs remain recognizable; linked accounts require
the originating proxy's snapshot. This cache only controls the visual indicator.
| env | meaning |
|---|---|
NATS_URL |
broker for proxy.system.* / proxy.transfer.* (default nats://nats.infra:4222) |
PROXY_ID |
this proxy's identity, recorded in a player's session — must differ per proxy (velocity, velocity-2) |
GROUNDS_ENVIRONMENT |
edition snapshot scope shared by all proxies in the same environment, e.g. stage |
GROUNDS_TOKEN_FILE |
projected SA-token, presented as the NATS bearer and as the service-config gRPC bearer (default /var/run/secrets/grounds/token) |
CONFIG_SERVICE_URL |
service-config contract target, e.g. service-config:9000. Unset disables /motd entirely and Velocity's own MOTD is served |
CONFIG_GRPC_TARGET |
Legacy fallback for deployments that have not migrated to CONFIG_SERVICE_URL |
CONFIG_ENV |
which environment's document to use; falls back to GROUNDS_PERMISSION_ENVIRONMENT |
CONFIG_APP |
which service-config app holds it (default velocity — deliberately not the release name, so velocity and velocity-2 share one MOTD) |
MOTD_REFRESH_SECONDS |
how often each proxy re-reads it (default 15) |
REGION |
{{region}}, and the region /region considers "here" |
CONTINENT |
{{localzone}} / {{continent}} |
On a proxy that also runs Floodgate — only velocity-bedrock does — the endpoint additionally
publishes which platform Bedrock players are on:
velocity_bedrock_players{device_os="ANDROID"} Floodgate's DeviceOs, by enum name:
ANDROID, IOS, OSX, XBOX, NX, PS4, UWP, …
Nothing else in the estate can answer this. The device travels in a Bedrock client's login chain, Geyser hands it to Floodgate, and by the time Velocity sees the player they are an ordinary Java-protocol connection with the platform stripped off — the game servers cannot tell a Switch from a phone.
Floodgate is read reflectively and is not a build dependency: this plugin loads on every proxy
and only one of them has Floodgate, and GeyserMC publishes the API as a SNAPSHOT only. A proxy
without Floodgate publishes no velocity_bedrock_players series at all — absent rather than zero,
because "no Bedrock players" and "cannot see Bedrock players" are different states.
A platform that empties keeps its series and reports 0, so a graph shows nobody on a Switch rather than a gap.
The NATS auth-callout scopes each pod to the subjects declared in its bundle events: block, so proxy.system.* and proxy.transfer.* must be listed there — an undeclared subject is denied and the message vanishes.
./gradlew build -Pgithub.user="$GITHUB_ACTOR" -Pgithub.token="$GITHUB_TOKEN"Published on tag as gg.grounds:plugin-proxy-api (consume this) and plugin-proxy-velocity, and as ghcr.io/groundsgg/plugin-proxy — the image carries the shaded JAR at /jar/plugin.jar for the plugin-velocity-jar Helm chart.