Player-facing text as keys, resolved into the language the player actually speaks.
Today a message is written where it is sent:
source.sendMessage(Component.text("Player not found.", NamedTextColor.RED))That line decides three separate things at once — the wording, the colour, and the
language — and it decides the language as English, always. A German client that
announced de_DE on login gets English anyway, and the only way to change the wording
is a code change by someone who can build a plugin.
This library separates the three. The call site names a message and its semantic level; the bundle holds the wording and inline highlights in every supported language. The shared renderer supplies fixed context colours and level-dependent system body colours.
messages.send(source, SocialMessage.PLAYER_NOT_FOUND)1. Declare the keys. An enum, so the set of messages a plugin can emit is closed and every one of them is greppable:
enum class SocialMessage(
override val id: String,
override val level: SystemLevel,
) : SystemMessageKey {
PLAYER_NOT_FOUND("player.not-found", SystemLevel.ERROR),
FRIEND_REQUEST_SENT("friend.request.sent", SystemLevel.SUCCESS),
FRIEND_REQUEST_RECEIVED("friend.request.received", SystemLevel.INFO);
override val category = SystemCategory.FRIENDS
}2. Write the source bundle at src/main/resources/gg/grounds/social/messages.properties.
Values are MiniMessage:
player.not-found=Player not found.
friend.request.sent=Friend request sent to <text><target></text>.
friend.request.received=<text><sender></text> sent you a friend request. Use /friend accept <sender> or /friend deny <sender>.3. Build one Translations per plugin and send through it:
private val messages = Translations.forBundle("gg.grounds.social.messages", javaClass.classLoader)
messages.send(player, SocialMessage.FRIEND_REQUEST_SENT, "target" to target.username)render(key, locale, …) returns the Component instead, for lore, inventory titles,
book pages and anything else that is not chat.
send puts the platform's tag in front of the line:
[Grounds] Friends | You are now friends with Ada!
Nothing at the call site opts in. A plugin conforms by doing nothing, which is the only
version of "everyone follows the house style" that survives the tenth plugin — the
alternative is a PREFIX constant per repo, which is how chat ended up with [Party],
[STAFF] and nothing at all, all at once.
The shared layout bundle controls the brand, localized category, separators and placeholder order. With verified pack readiness the header is [Logo] · Friends |; otherwise it is [Grounds] Friends |. The logo stays white and separators use secondary (#C1B9AA). Context colours are fixed, independent of level. The level owns the body root colour, including legacy bundle root colours; explicit child highlights, fonts and click/hover events survive.
| Level | Body base colour |
|---|---|
INFO |
warm white #F3F0EA |
SUCCESS |
soft green #A4CE87 |
WARNING |
amber #F0C36A |
ERROR |
coral #F28B82 |
SystemMessageColors owns this palette and fixed context colours: General #C1B9AA, Friends #79C9BE, Party #B8A0DA, Match #8EB8E8, Story #E7B84B. System status must be declared in SystemMessageKey.level; it is never inferred from wording, key names or bundle markup.
Implement SystemMessageKey to give a key an explicit SystemCategory. Existing MessageKey implementations use GENERAL context with INFO level. render stays unprefixed for menus and connection dialogs.
forBundleWithSystemRenderer accepts a host callback without changing existing forBundle JVM signatures. Use SystemMessageRenderer.withLogoReadiness with verified readiness for the actual recipient. Without that callback the output is text only. Do not infer readiness from a pack being sent or accepted.
sendRendered wraps already assembled rich content with the same recipient-specific system header. ChatLayouts.player supplies the common player layout with sender/message component placeholders; translations may reorder them. Free player text must remain unparsed.
Three entry points, and the difference between them is only who is talking:
| Call | Prefixed | For |
|---|---|---|
send |
yes | the platform talking to a player — confirmations, errors, announcements |
sendUnprefixed |
no | a player talking — chat lines, private messages, staff chat |
render |
no | not chat at all — lore, titles, inventory names, book pages |
A bundle that holds only conversation lines can opt out once, at construction, instead of at every call:
Translations.forBundle(bundle, loader, prefix = Prefix.NONE)Which lines are conversations is plugin-chat's call, not each plugin's: it owns the
channels and defines their shapes. Everything else is the platform speaking and is
prefixed.
A game or system area (SurvivalGames, Queue, Party, …) uses a named context in the same system header:
private val messages =
Translations.forBundle(
"gg.grounds.survivalgames.messages",
javaClass.classLoader,
prefix = Prefix.category("SurvivalGames"),
)grace.ending=The grace period ends in <text><seconds></text> seconds.
fight.begins=The grace period is over. Fight!send renders [Grounds] SurvivalGames | The grace period ends in 10 seconds., or
[Logo] · SurvivalGames | … when the host confirms pack readiness. SystemMessageRenderer
handles both forms. The body uses the key's level colour; for example declare a warning
with SystemLevel.WARNING rather than relying on a leading bundle colour tag.
Prefix.category(String) selects the fixed palette by stable untranslated context identifier.
Known game and area identifiers include GetDown, BedWars, Duel, Keystone, SurvivalGames,
Lobby, Chat and Settings. Unknown identifiers use General sand. Prefix.category(Component)
preserves an explicit context colour, otherwise using sand. sendUnprefixed and render
stay bare and retain their authored colours.
Bundle values are MiniMessage, so a translator can add inline highlights. Left alone that means
<red>, <green>, <gray> — sixteen colours from 2011 that have nothing to do with the
Grounds design tokens. Translations therefore renders with the semantic tags added:
player.not-found=Player not found.
friend.request.sent=Friend request sent to <text><target></text>.
friend.list.offline=<muted> (offline)| Tag | Token | Use |
|---|---|---|
<text> |
--text |
body text |
<muted> |
--text-muted |
secondary inline hints and subordinate values |
<faint> |
--text-faint |
brackets, punctuation |
<accent> |
--accent |
interactive text only — links, click-to-run |
<accent-deep> |
--accent-deep |
subdued accents in authored content |
<success> <warning> <danger> <info> |
state colours | what they say |
These are the dark set of tokens. There is no light set here: a client cannot tell us what its chat background looks like, and over a Minecraft world there is one right answer.
These tags remain available for authored UI and conversation styles and deliberate inline
highlights. System header colours and the body base follow SystemMessageColors instead:
the text brand is gold, INFO is warm white, and the other levels use the Mini-CD status
colours listed above. A leading colour tag cannot override a system message's level.
Use explicit child components for deliberate highlights and keep <accent> for actions.
The standard tags still parse, so no existing bundle breaks. What changed is that <red>
now reads as a deviation sitting next to <danger>, which is the only enforcement that
survives contact with a translator.
Palette also exposes the same colours as TextColor constants, for code that builds a
Component by hand rather than through a bundle.
"target" to name fills <target>. A String argument is inserted as unparsed
text, so a player named <red>x</red> — or, more to the point, a chat message quoted
back into another message — cannot inject markup. Pass a Component when formatting
is genuinely intended:
messages.send(player, SocialMessage.FRIEND_REQUEST_SENT,
"target" to Component.text(target.username, NamedTextColor.AQUA))Placeholder names are MiniMessage tag names: lowercase, [a-z0-9_-].
Identity.LOCALE — the locale the client announced. Paper, Velocity and Minestom all
expose it through that same Adventure pointer, so this works identically on all three.
An audience that announced nothing gets the fallback (English). A locale with no
bundle gets the fallback too, and specifically not the JVM default locale, which
ResourceBundle would otherwise use — that would make a container's LANG decide
what players read.
A stored per-player override (/lang de) does not exist yet. When it does, it goes in
through LocaleResolver without touching any call site:
Translations.forBundle(bundle, loader, localeResolver = LocaleResolver { audience ->
audience.get(Identity.UUID).map(overrides::get).orElse(null)
?: LocaleResolver.FROM_AUDIENCE.localeOf(audience)
})A key with no translation in any bundle renders as the key itself and logs once. It is ugly on purpose: sending nothing would look like a dropped packet to the player and like nothing at all to us.
Domain strings live in the plugin that sends them. This library ships only shared layout, context and level labels; plugins own their domain bundles.
The alternative — one central bundle for the whole platform — means every wording fix is a release of this library followed by a version bump in every plugin that depends on it. Strings change far more often than translation machinery does, so they belong on the faster release train.
In the same PR as the English line. Whoever adds a key to messages.properties adds
it to every messages_<lang>.properties too — one per language in SupportedLocales.ALL
other than English. There is no translation tool and no sync job; the bundles in the
repo are the whole truth, and reviewing a translation is reviewing a diff.
BundleCheck keeps that honest. One test per plugin:
@Test
fun `messages are complete`() {
assertEquals(
emptyList(),
BundleCheck.problems("gg.grounds.social.messages", javaClass.classLoader, SocialMessage.entries),
)
}It fails the build when a MessageKey has no English line, when a language file is
missing, lacks a key or keeps one the source dropped, or when a translated line's tags
differ from the English one — a translated <target> renders as nothing, and a
forgotten one silently drops the player's name.
Adding a language to SupportedLocales.ALL therefore fails every plugin's check until
that plugin translates it, which is the point: a language a player can pick but no
bundle speaks reads as broken.
dependencies {
implementation("gg.grounds:library-i18n:<version>")
}Adventure and MiniMessage are compileOnly here and must come from the platform.
Paper, Velocity and the Minestom runtime each ship their own copy — do not shade
Adventure into a plugin. A shaded copy gives you a second Component type that the
server's sendMessage will not accept.
Compiled against Adventure 4.17.0. Running against a newer Adventure is fine; the reverse is not, which is why the compile target is deliberately old.
- Plurals.
"3 friends online"needs ICU plural rules to be right in Polish or Russian, and acount == 1shortcut is wrong in a way that only shows up for languages nobody on the team reads. Add ICU MessageFormat when a plural string actually appears, not before. - Hot reload. Fixing a typo currently needs a plugin release. If that becomes the common case, the shape is CI publishing bundles to object storage and a NATS event telling servers to reload — with the classpath bundle staying as the fallback. Still no service on the send path.
- A translation service. Rendering happens per player, per message, in the chat path. A network call there buys nothing that a few kilobytes of properties files in memory do not already give.
SystemCategory describes the subject (GENERAL, FRIENDS, PARTY, MATCH, STORY). SystemLevel describes its semantic status (INFO, SUCCESS, WARNING, ERROR). Typed keys default to GENERAL and INFO; ordinary MessageKey implementations remain supported.
A failed friend operation therefore uses FRIENDS with ERROR: the visible label stays “Friends”, while the renderer uses the error token for the label. It never infers metadata from message wording or body colours. The body component and its events/styles remain intact. Default layouts show <category>; translations can additionally place the localized <level> placeholder. Both text and graphical layouts use the same metadata.
enum class FriendsMessage(override val id: String, override val level: SystemLevel) : SystemMessageKey {
LIST_FULL("friends.list-full", SystemLevel.ERROR);
override val category = SystemCategory.FRIENDS
}The new chat API is still unreleased; its consumer PRs migrate with it. Existing published Translations.forBundle factories are unchanged.