Skip to content

platform-velocity - Velocity Plugin (Proxy Relay)

platform-velocity is a thin layer whose only job is to relay cross-server global chat.

Its substance is just LunaticChat / BuildInfo / two files under messaging/; it has no command classes. Note that while /lcv is a Velocity-related feature, the command implementation lives on the platform-paper side.

Why the Velocity side is thin

The protocol definition is held by engine, and all chat state (channels, DMs, settings) lives on the Paper side. The only responsibility left to Velocity is "distribute a received global chat message to the other servers", so this layer is intentionally kept thin. Neither platform owns the protocol; both depend on engine as equal peers (see the Design Overview).

Lifecycle

  • LunaticChat (@Plugin) — receives ProxyServer / Logger / PluginContainer via a Guice @Inject constructor
  • The version in the @Plugin annotation is fixed at "0.0.0" and is not used at runtime. The real version is obtained from velocity-plugin.json via PluginContainer.description.version (startup fails if it is missing)
  • @Subscribe onProxyInitialization creates CrossServerChatRelay, then creates and initialize()s PluginMessageHandler with it injected
  • @Subscribe onProxyShutdown calls messageHandler.shutdown()

Message reception and dispatch

PluginMessageHandler handles reception on the lunaticchat:main channel. In initialize() it calls channelRegistrar.register(CHANNEL) and subscribes to events.

@Subscribe onPluginMessage processing:

  1. Ignore if event.identifier != CHANNEL
  2. Warn and discard if the source is not a ServerConnection
  3. Branch on the result of PluginMessageCodec.decode() with when
  4. Handshake → check compatibility and reply with HandshakeResponse / StatusRequest → reply with StatusResponse / GlobalChatMessage → delegate to the relay / otherwise (a Velocity-originated response type) → warn only

Trust boundary: rejecting client-originated messages

The check for whether the source is a ServerConnection is not just a type guard — it is a trust boundary.

Velocity plugin messages can arrive not only from backend servers but also from clients. By rejecting anything other than a backend connection here, it prevents clients from directly injecting global chat or forged handshakes. Only messages from trusted server connections are relayed.

Handshake handling

On receiving a Handshake, it judges compatibility via the engine's ProtocolVersion.isCompatible(major, minor).

  • Compatible — reply with HandshakeResponse where compatible=true
  • Incompatible — reply with compatible=false and an error string carrying both the Paper-side and Velocity-side versions

HandshakeResponse / StatusResponse always carry Velocity's own ProtocolVersion (MAJOR / MINOR / PATCH), so the Paper side can learn the peer's protocol from the response.

Cross-server relay

CrossServerChatRelay.relayGlobalMessage(message, sourceServer) is the heart of the relay.

server.allServers
    .filter { it != sourceServer }   // exclude the source
    .forEach { it.sendPluginMessage(CHANNEL, encoded) }

It excludes the source server and broadcasts to all remaining backends (stage one of echo prevention). The relay count is logged.

What gets relayed / what stays local

Keeping the relay scope minimal is a key design point.

  • The only thing Velocity relays to other servers is the GlobalChatMessage
  • Handshake / HandshakeResponse / StatusRequest / StatusResponse complete between Velocity and a single Paper, and are not forwarded
  • DM and channel chat are never sent to Velocity at all (they complete locally within Paper)

Two-stage echo/loop prevention

To keep global chat from being displayed multiple times through relay loops, it is prevented in two places.

  1. Velocity side — broadcast excluding the source server
  2. Paper side — a dedup LRU cache keyed by messageId (TTL 60s). The sender also registers its own messageId right after generation to prevent an echo on its own server

Message flow

  1. Paper sends a Handshake (its own protocol version), triggered by a player connecting
  2. Velocity judges with ProtocolVersion.isCompatible and replies with HandshakeResponse → if compatible, the Paper side becomes CONNECTED
  3. A player sends global chat (no active channel, or a ! prefix) → Paper sends a GlobalChatMessage (a new messageId) to Velocity and displays normal chat on the source
  4. Velocity relays to all backends except the source
  5. Each Paper receives it → dedups by messageId → formats with crossServerGlobalChatFormat and delivers to all players

Implementation notes

  • The plugin parameter of PluginMessageHandler is typed Any because Velocity's EventManager.register() takes an Object (the API itself isn't type-safe, so making it generic offers little benefit).
  • To run cross-server chat, Velocity's velocity.toml needs bungee-plugin-message-channel=true (plugin messaging enabled).