Skip to content

Minecraft Velocity Proxy

Custom Velocity proxy image that replaces the generic itzg/bungeecord:latest used previously. Same itzg runtime under the hood, but with two pre-baked Kotlin plugins built from source:

  • kbve-velocity-commands (apps/mc/velocity/plugin/) — teleport aliases, cross-server chat bridge, last-server persistence.
  • kbve-discord-relay (apps/mc/velocity/plugin-discord/) — Discord ↔ MC chat relay with prefix routing, reply context, and role-gated console commands.

The two plugins are independent (no source-level dependency, separate @Plugin IDs, separate version numbers) so they can be enabled, disabled, and rolled back independently. They ship together in the same image because they target the same Velocity proxy and need the same JDK/runtime stage.

Brigadier commands that wrap Player.createConnectionRequest(server).fireAndForget():

CommandTarget
/lobby, /hublobby backend (PaperMC lobby)
/mc, /survivalmc backend (Fabric survival)

Hardcoded aliases for v1. A config file for dynamic aliases is v2 scope. No-permission — any player can use them in v1; LuckPerms nodes like kbve.lobby can be added later.

Listens to PlayerChatEvent on Velocity. Per-player chat mode stored in an in-memory ConcurrentHashMap<UUID, ChatMode>, toggled via /chat:

CommandBehavior
/chatShows current mode (default global)
/chat localMessages stay on source server
/chat globalMessages broadcast to every backend with a server-tagged prefix

Local mode: the plugin doesn’t touch the event. Vanilla MC chat (<player> message) flows through with signed-chat verification + backend chat-filter pipeline intact, visible only to same-server players. The absence of a tag is the local indicator.

Global mode: the plugin denies the original PlayerChatEvent and re-emits a formatted Component to every player on every backend:

[Lobby] fudster: hi
[Survival] fudster: hi
  • [, ], : → dark gray (visual gutter)
  • Lobby → dark aqua, Survival → dark green (no yellow)
  • Player name + body → white (default chat color)

Tradeoff: signed-chat verification is bypassed for global only — the message reaches recipients as a system component, not signed chat. Local chat keeps full signing.

Session-scoped — the chat-mode map resets when the Velocity pod restarts. Persistent storage is v2 scope.

Tracks the last server a player was connected to via ServerConnectedEvent and restores it on PlayerChooseInitialServerEvent when they reconnect. Falls back to Velocity’s normal try list (which starts with lobby) if the stored server is missing or offline.

In-memory only for v1 — survives player disconnect/reconnect cycles within a single Velocity pod lifetime, not across pod restarts.

Bridges in-game chat into a single Discord channel and routes Discord messages back into the network. Standalone Velocity plugin — no DiscordSRV, no source dependency on kbve-velocity-commands. JDA 5 is bundled directly into the plugin’s fat jar.

Outbound (MC → Discord): every player chat message — LOCAL or GLOBAL — is posted to a webhook the bot self-provisions on startup (kbve-mc-relay, created via MANAGE_WEBHOOKS). The webhook username is set to <player> [L] or <player> [S] so each message renders as the player and Discord readers can see which backend it came from. Mode-independent on purpose: /chat local still appears in Discord with the [L]/[S] tag — the mode only affects whether the in-game cross-server bridge in kbve-velocity-commands fires.

Inbound (Discord → MC): the bot listens to the configured channel and parses prefixes:

Discord inputIn-game effect
hi everyoneBroadcast to all servers
>lobby hello / >hub helloLobby only
>mc hello / >survival helloSurvival only
reply to a [L] webhook messageLobby only (server inferred from the referenced message’s author tag)
reply to an [S] webhook messageSurvival only

Public commands (no role gate): >who / >list, >servers, >help.

Staff commands (gated by Discord role IDs 733334418747555918 or 647866541790068746, override via DISCORD_CMD_ROLES): >cmd <command> runs on the Velocity console; >kick <player> [reason], >ban, >mute are sugar over >cmd; >tell <player> <msg> DMs a single player; >say <msg> posts a network-wide staff announcement. Unauthorized callers are silently dropped (no reaction, no reply) so unprivileged users can’t enumerate the gated command set, with every attempt audit-logged at INFO.

Configuration:

Env varPurpose
DISCORD_BOT_TOKENJDA bot login (sealed in mc-discord-relay Secret)
DISCORD_CHANNEL_IDChannel the bot listens to and posts into (default 1501071171804991651)
DISCORD_CMD_ROLESOptional CSV override of authorized role IDs

If DISCORD_BOT_TOKEN is unset (e.g. the e2e container test) the plugin logs a single line and stays inert; kbve-velocity-commands continues working.

Multi-stage Dockerfile:

Stage 1: gradle:jdk21
└─► Gradle shadowJar → kbve-velocity-commands-<version>.jar
Stage 2: gradle:jdk21
└─► Gradle shadowJar → kbve-discord-relay-<version>.jar (bundles JDA 5)
Stage 3: itzg/bungeecord:latest
└─► COPY both JARs into /plugins/
└─► itzg auto-loads everything in /plugins/ at Velocity boot
└─► LuckPerms-Velocity also downloaded via PLUGINS env var

Both plugins use Velocity API 3.4.0-SNAPSHOT, Kotlin 2.0.21, KAPT for @Plugin annotation processing, and JDK 21.