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.
A fresh network join receives a single welcome block with the large 26px Grounds
wordmark used by the network HUD, the player's name, and independent Discord,
TikTok and Shop links in one horizontal row. The resource pack supplies sharp
pixel-art brand icons (Discord, TikTok and a shopping bag); both icon and label
open the link. Modern clients receive it after the first backend connection and
one acknowledgement round trip, with no fixed welcome delay. Required packs have
already loaded during configuration. A grounds:welcome_ready cookie request is
queued after JoinGame so client join-time mod announcements precede its response.
Clients without a verified HUD font pack see the
Grounds name instead of an unsupported glyph.
Discord defaults to https://grnds.link/discord, matching the lobby bossbar.
TikTok (https://example.com/grounds/tiktok) and Shop
(https://example.com/grounds/shop) are intentional placeholders. Replace these
with WELCOME_TIKTOK_URL and WELCOME_SHOP_URL; WELCOME_DISCORD_URL can override
Discord as well. Keep these values consistent across proxy deployments.
Clients that have not announced Simple Voice Chat receive a short description
and a link to the official installation guide, as a complete localized sentence
followed by the installation action. Client channel registration or a
voice handshake suppresses that section even if voice is disabled or UDP cannot
connect. Handshake messages continue to the backend unchanged. Server switches
do not repeat the welcome. A connection-scoped grounds:welcome client cookie
carries whether the block was delivered across proxy/region transfers: an early
redirect still gets its first welcome at the destination. Fresh logins reset
the marker before preferred-region routing. Disconnect and shutdown cancel
pending delivery.
Before rollout, check a fresh join on each proxy, a backend switch, and a proxy transfer. Check clients with the mod absent, present but disabled, and present with UDP blocked; only the first should get installation help. Also check a declined resource pack and a disconnect/reconnect before the ready-cookie reply. Only legacy protocols before 1.20.5 retain the old five-second negotiation grace.
The installation observers capture only an in-memory bit at maximum synchronous priority, then explicitly hand off to an asynchronous EventTask. This preserves packet order before the ready reply without moving other plugins' handlers onto the packet thread. Before changing the Velocity runtime, verify these dispatcher semantics against its actual JAR:
velocity/src/test/scripts/verify-welcome-dispatch.sh \
velocity/build/libs/plugin-proxy-velocity.jar /path/to/deployed-velocity.jarplugin-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.
/whereami is available to every player and shows their current region, full proxy
ID and current backend ID in one indented Grounds chat block. A short explanation
asks players to use these details when contacting support. Hover and click the
proxy or server ID to copy it. Missing values appear as —. The command reads the
current connection on whichever proxy handles it, so backend switches appear
immediately without a presence-service lookup. Region uses REGION, falling back
to the existing CONTINENT/region code when the datacentre is not configured.
All text is localized in English, German, French and Spanish.
The heading and body follow the Minecraft Mini-CD (warm white, sand and cyan
copy actions). Three first-party pixel icons in grounds:connection appear only
with verified HUD readiness and a compatible WHEREAMI_FONT_PACK_SHA1S allowlist;
otherwise the indented text labels remain. The icons use an additive platform
pack font and do not replace vanilla fonts or the existing welcome graphics.
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.
During an active maintenance window, every proxy reports a red Wartungsarbeiten
label in the server list's protocol/version field. Once the window ends or is
disabled, the normal version and player-count display returns on the next ping.
The maintenance document's motd is optional in effect: set it to an empty string
to keep the normal MOTD. A nonblank value overrides the description only while
maintenance is active. New maintenance drafts default to an empty override.
./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.
Welcome bitmap icons use grounds:welcome U+E400/U+E401/U+E402. Set WELCOME_FONT_PACK_SHA1S to the verified platform-pack hashes containing these glyphs and restrict HUD_FONT_PACK_SHA1S to that same set. Icons remain disabled unless every HUD-ready hash is covered; unknown packs show plain labels. Existing chat, sidebar and HUD pack allowlists must include a newly activated platform pack as appropriate.
The welcome can use the original full Grounds logo with floating stones. Only enable WELCOME_LOGO_PACK_SHA1S for HUD-approved hashes containing U+E403; otherwise the existing compact wordmark remains. Its40px artwork reserves more rows; with Voice help the block ends in normal-height text and still fits ten closed-chat rows. Voice copy and Install use the same sand-coloured bullet as the bossbar.