Localization gotchas
What surprises people using the translation stack in a game.
This page is about using the stack. Why it behaves this way — batched localization writes, per-key
readiness, the translator fallback chain, and the engine behavior all of it is built around — is
documented alongside the package, in
src/clienttranslator/docs/.
Go there if you are changing the stack, or if you want to know why something below behaves the
way it does.
Which locale file a player actually gets
A player's locale is matched to your locale files by language and script, not by exact name:
- Regional variants substitute. Ship
es-es.jsonand anes-mxplayer reads it. Same foren-gbreadingen-us, orpt-brreadingpt-pt. - Scripts never substitute. A
zh-twplayer will not readzh-cn.json. Traditional and Simplified are separate: ship both. Chinese carries its script in the region (zh-tw,zh-hk,zh-moare Traditional) as often as in the name (zh-hant), and both spellings work. - The same applies to any language with distinct scripts, such as
sr-latnandsr-cyrl. Where a file names no script at all it reads as either, sosr.jsonserves both — except in Chinese, where a barezh.jsoncounts as Simplified.
When nothing matches, the player gets the source locale — usually your en.json.
A key with no translation renders as the key
If no locale file and no uploaded cloud translation resolves a key, the text you get back is the
translation key itself (quests.groups.theBeginning), and one warning is logged. That is the
signal that a key is missing everywhere, not just in the current language — a key missing only in
the current language quietly falls back to the source locale instead.
Keys you generate at runtime belong to the realm that generated them
JSONTranslator:ToTranslationKey(prefix, text) hands back a key and registers text as the
source behind it — into that realm's localization table. The server and the client each have their
own (GeneratedJSONTable_Server and GeneratedJSONTable_Client), and neither sees the other's.
So if your server turns an authored string into a key and sends the client only the key, the client has nothing to render but the key. Uploaded cloud translations do not cover it either: they only know keys that existed when you exported the CSV, and this one was generated at runtime.
Mint on the realm that renders. Send the text — or the prefix and the text — and let the client
call ToTranslationKey or ObserveTranslation, which registers the source as it derives the same
key:
-- Server: mint so the string lands in the CSV export, but send the text, not the key.
translator:ToTranslationKey("npcs", displayName)
remote:FireClient(player, displayName)
-- Client: the same call derives the same key, and registers the source while doing it.
maid:GiveTask(translator:ObserveTranslation("npcs", displayName):Subscribe(function(text)
label.Text = text
end))
Unlike a key whose data is merely still queued, this does not fix itself a frame later — there is no translation on the way.
Text may correct itself a frame after it appears
ObserveFormatByKey emits immediately so a label is never blank, then re-emits the real
translation once the key's data is in place. A label can therefore show the raw key for a frame on
first load. It will not flicker backwards: once you have a good translation, a locale swap replaces
it with the new language's text and never with a fallback.
You do not need to wait for anything to make this work. Every read path — ObserveFormatByKey,
PromiseFormatByKey, FormatByKey, ObserveTranslation — already handles it.
A generated key is a data format, not a detail
ToTranslationKey and ObserveTranslation derive their key from your source text, so that key ends
up written down: in whatever locale tables your build step produced, in the cloud localization table,
and in any key your game replicates or stores. None of that is regenerated when the derivation
changes.
That makes the derivation frozen, and it is pinned by vectors in TranslationKeyUtils.spec.lua. If
you maintain your own build step that has to produce the same keys, derive its vectors from that
module rather than reimplementing the rule — the two drifting apart is invisible until a player sees
English, because an unbound key falls back to its registered source text.
One entry per translation key
A LocalizationTable treats the translation key as the whole identity of an entry. Registering the
same key twice with a different source or context does not create a second entry — the later write
overwrites the source and context of the first. Plan keys to be unique on their own; do not rely on
context to disambiguate two uses of one key.