Skip to content

Localization

Decided, not implemented

This is the agreed architecture. None of it is in the project yet — the Localization package is not in Packages/manifest.json and no tables exist. It is wave 1 work, deliberately early: retrofitting i18n after a game has content is significantly more expensive than starting with it.

The shape of it

Two layers, split on purpose.

Unity Localization (com.unity.localization) handles storage and loading: String Tables, Asset Tables, locale selection, Addressables-backed on-demand loading, per-locale fonts and per-locale assets. It is the right tool for that and there is no reason to rebuild it.

ICU MessageFormat handles grammar: plurals, gender, select, ordinals, number and date formatting. Unity's own Smart Strings are a different, non-standard syntax that translators do not know and existing tooling cannot validate.

flowchart LR
    Ink[Ink stories<br/>#id: tags] -->|extract| CSV[CSV / Google Sheets]
    CSV -->|import| ST[Unity String Tables]
    ST --> Loc[Loc facade]
    ICU[ICU MessageFormat<br/>formatter] --> Loc
    Loc --> LTMP[LocalizedTMP component]
    Loc --> Code[Runtime C# calls]
    AT[Unity Asset Tables] -->|fonts, sprites, audio| LTMP
    Addr[(Addressables)] --- ST
    Addr --- AT

The Loc facade

All localized text goes through one static entry point. Nothing in the game calls LocalizationSettings directly.

Loc.Get("ui.hud.water_gauge");
Loc.Get("area.lowtown.pumpmaster.line_012", new { name = playerName, count = taps });

Loc pulls the raw string from the Unity String Table and runs it through the ICU MessageFormat formatter with the supplied arguments. Two consequences:

  • Smart Strings are disabled on every table. Two formatting engines fighting over the same braces is a bug factory, and ICU wins because it is the standard.
  • Swapping the storage layer later touches one class.

LocalizedTMP

A component on every TMP_Text that displays authored text. It holds a key, subscribes to locale change, calls Loc.Get, writes the result, and pulls its font from the Asset Table for the active locale.

Rule: no literal display strings in scenes or prefabs. If a TMP_Text shows words a player reads, it has a LocalizedTMP and a key.

Ink lines to keys

Ink stories stay the authoring format. Every line that ships carries an #id: tag whose value is the string-table key:

Hello there. You must be the one with the hose. #speaker: Pump Master #id: area.lowtown.pumpmaster.line_001

Pipeline:

  1. Write English in the .ink file as normal.
  2. An extraction step walks the compiled story, reads each #id: tag, and writes key + English source into the String Table.
  3. At runtime, StoryFunctions.HandleTags reads the id tag and the manager renders Loc.Get(id) instead of the Ink line body — the Ink text becomes the English source of truth, not the display string.
  4. Lines without an #id: tag fail the build check. Untagged shipped text is the failure mode this whole design exists to prevent.

This requires changes to HandleTags and ContinueStory — see Dialogue system.

Core locales

Nine, chosen for reach rather than sentiment:

Code Language Notes
en English source locale
ja Japanese CJK font, vertical-ish line-breaking rules
zh Chinese CJK font; script variant (Hans/Hant) decision deferred
es Spanish gendered — needs ICU select
ko Korean CJK font, particle agglutination
it Italian gendered
pt Portuguese gendered; variant (pt-BR) decision deferred
hi Hindi Devanagari, complex shaping
ru Russian ICU plural categories one/few/many/other

Russian alone justifies ICU: a plural rule with four categories cannot be expressed as {0} taps.

Fonts

TMP fallback chains, assigned per locale through the Asset Table:

  • A Latin primary for en, es, it, pt.
  • Cyrillic coverage for ru — usually the same family, extended character set.
  • Devanagari for hi, which needs a font with proper shaping, not just glyph coverage.
  • CJK for ja, zh, ko. These are large; they load through Addressables per locale rather than being resident.

Set fallbacks so a missing glyph degrades to a box in one script instead of failing the whole line. Test each locale with the longest string in the table, not the shortest — German-length overflow is a layout bug and layout bugs are found by looking.

Key naming

Dotted, lowercase, snake_case segments. Two families:

area.npc.line_001          area.lowtown.pumpmaster.line_001
ui.<screen>.<element>      ui.hud.water_gauge
Rule Why
Keys are never English text a key that is a sentence gets edited when the sentence gets edited, and every translation breaks
Numbered lines are zero-padded to three digits line_009 sorts before line_010 in every tool a translator opens
One table per area, plus a ui table tables load per area through Addressables; a town does not pay for the summit's text
Keys are immutable once translated rewording English changes the value, not the key. Deleting a line deletes the key.

Translator workflow

Translators do not open Unity.

  1. Export. String Tables export to CSV — Unity Localization ships this.
  2. Sheet. The CSV lands in a Google Sheet, one column per locale, plus a context column for notes such as speaker, tone and character limit. Context is not optional: "Pump" alone is untranslatable in half these languages.
  3. Translate. Translators work in the sheet. ICU syntax stays intact because the sheet shows it plainly, and it can be validated with off-the-shelf ICU linters.
  4. Import. The sheet comes back as CSV and imports into the tables. Unity Localization also supports a direct Google Sheets connection, which is worth using once the workflow is proven over CSV.
  5. Review in game. Every locale gets a pass in the actual game before it ships. Text out of context is text translated wrong.

Open items

  • Whether zh ships as Hans, Hant, or both.
  • Which ICU MessageFormat implementation for C# — needs to be evaluated for IL2CPP and trimming behaviour before it is committed to.
  • Whether audio is localized at all, or subtitles only.
  • Extraction tooling for #id: tags: an editor script versus a step in tools/.