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:
- Write English in the
.inkfile as normal. - An extraction step walks the compiled story, reads each
#id:tag, and writes key + English source into the String Table. - At runtime,
StoryFunctions.HandleTagsreads theidtag and the manager rendersLoc.Get(id)instead of the Ink line body — the Ink text becomes the English source of truth, not the display string. - 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:
| 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.
- Export. String Tables export to CSV — Unity Localization ships this.
- 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.
- 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.
- 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.
- 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
zhships 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 intools/.