Skip to content

Localization

Ship your game in more than one language. Text, plural forms, and even artwork that needs to differ per language.

Placeholder: a settings screen language dropdown listing each language in its own script

How it works

You keep one file per language in Assets/Localization/, named after the language code:

Assets/Localization/en.json
Assets/Localization/es.json
Assets/Localization/ja.json

The file name is the language codees.json is Spanish. Nothing inside the file says which language it is, so renaming the file is how you change that.

Each file is a flat list of keys and the text they stand for. en.json is your source of truth; the others are translations of it.

json
{
  "hud.coins": "Coins: {0}",
  "language.name": "English",
  "menu.play": "Play",
  "menu.quit": "Quit"
}

Three rules make the tooling work:

  • Keys are dotted names, like menu.play — they describe where the text is used, not what it says. menu.play survives someone deciding the button should read "Start".
  • Keys are sorted alphabetically. The validator checks this, and it's what keeps a diff between two translations readable.
  • language.name is reserved — put the language's own name for itself there ("Español", "日本語"). That's what a language picker shows, because a Spanish speaker looking for their language is not looking for the word "Spanish".

Text that translates itself

The easiest path needs no code at all. A Text Component has a Localization Key field: set it to menu.play and the label shows whatever the current language's catalog says. Change the language and every one of those labels follows, with nothing to subscribe to and nothing to refresh.

Clear the field to go back to plain, untranslated text.

Text a script builds

For item names, dialogue, or anything assembled at runtime:

csharp
label.Text = Loc.Get("menu.play");

Loc.Get always returns something: the current language, falling back to English, falling back to the key itself. A missing translation shows up as menu.play on screen — visible and obviously wrong, rather than a blank space you don't notice until someone reports it.

Values inside text

Placeholders are numbered, and Loc.Format fills them in:

json
{ "hud.coins": "Coins: {0}" }
csharp
label.Text = Loc.Format("hud.coins", coinCount);

Numbered placeholders rather than inline substitution because word order changes between languages — a translator has to be able to move {0} to the front of the sentence.

Plurals

"1 enemy" and "2 enemies" is the easy case. Polish has four forms; Japanese has one. Loc.Plural picks the right one for the current language:

json
{
  "enemy.defeated.one":   "Defeated {0} enemy",
  "enemy.defeated.other": "Defeated {0} enemies"
}
csharp
label.Text = Loc.Plural("enemy.defeated", killCount);

You write sibling keys sharing a prefix, one per plural category — zero, one, two, few, many, other. The count both picks the category and fills {0}.

A single .one key is not a plural

A plural is detected by finding two or more siblings with different category suffixes. So chapter.one on its own stays an ordinary string, which is what you want — but it also means a plural with only .one written and no .other yet won't behave as a plural. The validator catches this.

Switching languages

csharp
Loc.Language = "es";

Everything using a Text Component's Localization Key updates on its own. If your script built some text by hand, subscribe so you can rebuild it:

csharp
protected override void OnCreate()
{
    Loc.OnLanguageChanged += RefreshLabels;
}

The choice persists, so the game comes back in the same language next time.

Building a language picker

Loc.Available gives you every language it found, each with its code and its own name for itself — exactly what a dropdown needs:

csharp
foreach ((string code, string nativeName) in Loc.Available)
    AddLanguageOption(code, nativeName);

Don't call it every frame; it builds a small array each time. Populate the menu once when it opens.

On first run the game starts in the operating system's language if you have a catalog for it, and in English if you don't.

Artwork that differs per language

Sometimes it isn't text — a sign in the level, a logo with words baked into it, a voice line. Put a variant next to the original with the language code before the extension:

Assets/Textures/Sign.png
Assets/Textures/Sign.es.png
Assets/Textures/Sign.ja.png

Reference Sign.png as normal. In Spanish the engine finds Sign.es.png and uses it; where no variant exists it falls back to the base file. You only make variants for the handful of assets that actually need one.

The Localization panel

Settings → Localization → Open Panel… opens the editor's own view of your catalogs — which keys exist, which languages are missing them, and the CSV/XLIFF import and export buttons described below.

Checking your translations

Run the validator over your localization folder:

bash
python3 scripts/validate_loc.py Assets/Localization

It checks the things that are tedious to check by hand and embarrassing to ship:

  • The file is valid JSON, UTF-8, and every value is a string
  • Keys are in alphabetical order (--fix sorts them for you)
  • Every language has exactly the keys en.json has — nothing missing, nothing left over from a key you renamed
  • Placeholders match — if English says {0} and the translation drops it, that's a crash or a blank number, and it's caught here
  • Every plural covers the categories its language actually requires

Run it before you ship, and ideally whenever translations come back.

Fonts have to have the glyphs

A font with no Japanese glyphs renders Japanese as boxes. The engine checks font coverage when it builds your game and fails the build rather than shipping tofu — so if you add a language, check the font that will display it.

Working with translators

Translators don't want JSON. Catalogs round-trip through CSV and XLIFF, which is what translation tools actually take, and the export records a fingerprint of the English text for each key — so when you reword an English string, the next export can tell you which translations are now out of date instead of leaving them quietly wrong.

Habits worth having

  • Key by location, not by content. menu.play, not play_button_says_play.
  • Never concatenate translated fragments. Loc.Get("you_have") + count + Loc.Get("apples") is unbuildable in most languages. One key, one whole sentence, with placeholders.
  • Leave room in your UI. German is routinely 30% longer than English. A button sized exactly to "Play" will not fit "Wiedergabe".
  • Start early. Adding keys as you build is easy; extracting hard-coded strings from a finished game is not.

See also