12.3 Localization

Phase 12 · UI / UX Programming · Study time: 10–20 h

Supporting many languages — text, fonts, layout direction and cultural formatting. Essential for a global launch like HoYoverse's.

A game like the ones made by HoYoverse ships in twelve or more languages on day one, and keeps adding more after launch. Every button label, every quest description, every item name, every line of dialogue has to exist in all of them, stay correct as the game updates, and load fast on a phone. This chapter covers localization (adapting a product for a specific language and region — often shortened to l10n, the "10" standing for the ten letters between the "l" and the "n") from a programmer's point of view: how code should be written so translation never touches it, and the specific traps — grammar, plurals, dates, layout, fonts, direction of reading, and audio — that break naive approaches.

1. Why You Never Hard-Code Display Text

Hard-coding text means writing the actual English words directly inside your game logic, like this:


using UnityEngine;
using UnityEngine.UI;

public class StartButton : MonoBehaviour
{
    void Start()
    {
        // BAD: the English sentence lives inside the C# file.
        GetComponentInChildren<Text>().text = "Start Game";
    }
}

This works fine for a game in one language. It falls apart the moment you need a second one. To add Japanese, someone has to open this exact file, find this exact line, and change it — and there are thousands of lines like it scattered across hundreds of scripts. Ship in twelve languages and you would need twelve versions of every script, or twelve if branches inside every script, just to print text.

Common mistake Thinking "I will just add languages later." Later means retrofitting localization into code that was never designed for it: hunting down every hard-coded string across the whole codebase, one by one, often missing several, and shipping a build with a few buttons still stuck in English inside an otherwise-translated menu.

There is a second, quieter problem: translators are not programmers. They should never need to open a C# file, understand MonoBehaviour, or risk breaking a build just to fix a typo in a sentence. Hard-coded text forces exactly that. The fix, covered in the rest of this chapter, is to separate what text should appear (a job for translators) from where and when it appears (a job for code) — and the tool that separates them is a lookup table.

2. The Key and String Table Idea

Instead of writing the English sentence in your code, you write a short, stable key (a unique identifier, chosen by a programmer, that never itself gets shown to a player — something like "menu.start"). A separate string table (a lookup structure mapping each key to its translated text, one table per language) holds the actual words. Code only ever asks: "give me the text for this key, in the player's current language."

KEY en th ja -------------------------------------------------------------------------- menu.start Start Game เริ่มเกม ゲーム開始 menu.settings Settings ตั้งค่า 設定 menu.quit Quit ออกจากเกม 終了 item.found You found {0}! คุณพบ {0}! {0}を見つけた! Code never contains the words "Start Game" or "เริ่มเกม" directly. It only ever asks the table for the row named "menu.start", and the table hands back whichever column matches the current language.

The key "menu.start" never changes no matter how many languages you add. Only the table grows — one new column per language. Here is the idea as plain C#, using a small hand-typed table just to show the mechanism (a real table is loaded from a file, covered in Section 12):


using System.Collections.Generic;
using UnityEngine;

Dictionary<string, string> englishTable = new Dictionary<string, string>
{
    { "menu.start", "Start Game" },
    { "menu.settings", "Settings" },
};

Dictionary<string, string> thaiTable = new Dictionary<string, string>
{
    { "menu.start", "เริ่มเกม" },
    { "menu.settings", "ตั้งค่า" },
};

string keyToShow = "menu.start";
Debug.Log(englishTable[keyToShow]);
Debug.Log(thaiTable[keyToShow]);

Expected output:


Start Game
เริ่มเกม

Same key, keyToShow, unchanged. Only which dictionary is asked changes — and that is exactly the design you want: one place decides "which language is active," and everything else just asks the table.

3. Building a LocalizationManager in C#

A real game does not keep a separate dictionary per language sitting loose in a script. It centralizes lookups behind one object: a LocalizationManager that owns every table and knows which language is currently active.


using System.Collections.Generic;
using UnityEngine;

public class LocalizationManager : MonoBehaviour
{
    public static LocalizationManager Instance { get; private set; }

    // key -> (language code -> localized text)
    private Dictionary<string, Dictionary<string, string>> table;
    private string currentLanguage = "en";

    void Awake()
    {
        Instance = this;
        table = LoadTable(); // a real game reads this from a file, Section 12
    }

    public void SetLanguage(string languageCode)
    {
        currentLanguage = languageCode;
    }

    public string Get(string key)
    {
        if (table.TryGetValue(key, out var perLanguage) &&
            perLanguage.TryGetValue(currentLanguage, out var text))
        {
            return text;
        }
        // Visible placeholder instead of a crash or blank text --
        // easy to spot a missing translation during testing.
        return "!!" + key + "!!";
    }

    private Dictionary<string, Dictionary<string, string>> LoadTable()
    {
        return new Dictionary<string, Dictionary<string, string>>
        {
            { "menu.start", new Dictionary<string, string> {
                { "en", "Start Game" }, { "th", "เริ่มเกม" }, { "ja", "ゲーム開始" } } },
            { "menu.quit", new Dictionary<string, string> {
                { "en", "Quit" }, { "th", "ออกจากเกม" }, { "ja", "終了" } } },
        };
    }
}

LocalizationManager.Instance.SetLanguage("th");
Debug.Log(LocalizationManager.Instance.Get("menu.start"));

LocalizationManager.Instance.SetLanguage("ja");
Debug.Log(LocalizationManager.Instance.Get("menu.start"));

Debug.Log(LocalizationManager.Instance.Get("menu.does_not_exist"));

Expected output:


เริ่มเกม
ゲーム開始
!!menu.does_not_exist!!

Nothing outside this one class needs to know how the table is stored, what file format it came from, or how many languages exist. Every other script just calls LocalizationManager.Instance.Get(key).

Tip Return a loud, visible placeholder like !!menu.does_not_exist!! for a missing key, never an empty string and never a silent crash. A blank label is easy to miss during testing; !!menu.does_not_exist!! jumps out on screen immediately, in any language.

4. Loading a Language at Runtime and Swapping All Text

A player's language is usually picked once — from the device's system language (Application.systemLanguage in Unity) as a default, or from a settings menu the player can change by hand. When the language changes mid-game, every piece of visible text on screen has to update immediately, without you writing code that manually finds and updates every label by hand.

The cleanest way to do this uses the same Observer pattern (a publisher announces something happened; any number of listeners react, without the publisher knowing who they are) covered in an earlier chapter. LocalizationManager raises an event whenever the language changes:


using System;
using System.Collections.Generic;
using UnityEngine;

public class LocalizationManager : MonoBehaviour
{
    public static LocalizationManager Instance { get; private set; }
    public event Action OnLanguageChanged;

    private Dictionary<string, Dictionary<string, string>> table;
    private string currentLanguage = "en";

    void Awake()
    {
        Instance = this;
        table = LoadTable();
    }

    public void SetLanguage(string languageCode)
    {
        currentLanguage = languageCode;
        OnLanguageChanged?.Invoke(); // tell every listener to refresh
    }

    public string Get(string key)
    {
        if (table.TryGetValue(key, out var perLanguage) &&
            perLanguage.TryGetValue(currentLanguage, out var text))
        {
            return text;
        }
        return "!!" + key + "!!";
    }

    private Dictionary<string, Dictionary<string, string>> LoadTable() { /* Section 3 */ return null; }
}

Each on-screen label is a small component that subscribes to this event and refreshes itself:


using UnityEngine;
using UnityEngine.UI;

public class LocalizedText : MonoBehaviour
{
    public string key; // e.g. "menu.start", set in the Inspector

    private Text label;

    void Awake()
    {
        label = GetComponent<Text>();
    }

    void OnEnable()
    {
        Refresh();
        LocalizationManager.Instance.OnLanguageChanged += Refresh;
    }

    void OnDisable()
    {
        LocalizationManager.Instance.OnLanguageChanged -= Refresh;
    }

    void Refresh()
    {
        label.text = LocalizationManager.Instance.Get(key);
    }
}
LocalizationManager.SetLanguage("ja") LocalizationManager --(raises)--> OnLanguageChanged event OnLanguageChanged --(notifies)--> StartButton.Refresh() text becomes ゲーム開始 OnLanguageChanged --(notifies)--> SettingsButton.Refresh() text becomes 設定 OnLanguageChanged --(notifies)--> QuitButton.Refresh() text becomes 終了 Each LocalizedText subscribed on its own in OnEnable. LocalizationManager does not know how many listeners exist, or which GameObjects they live on.

Expected result: calling SetLanguage("ja") once, anywhere in the game (a settings menu button, for example), instantly swaps every single piece of UI text on screen to Japanese in the same frame — no script has to be told individually. Add a hundred more labels later, and each new LocalizedText wires itself up the same way, automatically.

5. The Concatenation Trap: Use Placeholders, Not Glue

String concatenation means building a sentence by gluing separate pieces of text together with +. It looks harmless in English:


void ShowFound(string itemName)
{
    // BAD: glues three separate pieces into one sentence.
    label.text = "You found " + itemName + "!";
}

This breaks in other languages for reasons that have nothing to do with translation skill. Word order is not the same across languages — a translator working on the Japanese version cannot simply swap "You found" for a Japanese phrase, because in Japanese the item name comes before the verb, not after. A translator working on the German version may need an article ("der"/"die"/"das") in front of itemName that depends on that specific noun's grammatical gender — information this code never gives them. The three glued pieces assume one fixed word order, and that assumption is an English-only accident, not a rule of language in general.

The fix is a format string (one complete template stored per language, containing a placeholder — a marker like {0} that gets replaced with a value at runtime) instead of separate glued fragments. The whole sentence lives in the table, as one unit, so a translator can move the placeholder anywhere the target language's grammar needs it:

KEY en th de ja item.found You found {0}! คุณพบ {0}! {0} gefunden! {0}を見つけた! Each language gets ONE full sentence with a slot in it. The translator decides where {0} goes -- the code never assumes.

void ShowFound(string itemName)
{
    string template = LocalizationManager.Instance.Get("item.found");
    label.text = string.Format(template, itemName);
}

ShowFound("Sword");

Expected output (English active): You found Sword!. Switch the active language to Thai and, with no code change at all, the same call produces คุณพบ Sword! — the sentence structure came entirely from the table.

Common mistake Concatenating any localized fragment with +, even something that looks tiny — "Level " + levelNumber, or itemName + " (equipped)". Every one of these bakes in an English word order. Always store the full sentence, with a placeholder, as a single key.

6. Plurals and Gender Are Not Universal

English has exactly two plural forms: singular ("1 item") and plural ("2 items", "0 items"). It is tempting to write code around that assumption:


// BAD: assumes every language works like English's two-form rule.
label.text = count + " items collected";

This is wrong even in English ("1 items collected" reads oddly — it should be "1 item collected"), and it gets worse across languages. Thai does not mark plurals on nouns at all — one word covers any quantity. Arabic has six distinct plural forms (linguists call these plural categories: zero, one, two, few, many, other) with different grammar for each. Russian has three. A single if (count == 1) check only ever handles English's shape of the problem.

The fix follows the same idea as Section 5: store one full template per plural category, and pick the right category with a small per-language rule:


using System;
using System.Collections.Generic;

public static class Pluralizer
{
    // A tiny demo covering two shapes: English's one/other split,
    // and Thai's rule of always using the same form.
    private static readonly Dictionary<string, Func<int, string>> rules =
        new Dictionary<string, Func<int, string>>
    {
        { "en", n => n == 1 ? "one" : "other" },
        { "th", n => "other" }, // Thai nouns do not change for quantity
    };

    public static string FormattedCount(string baseKey, int count, string language)
    {
        string category = rules[language](count);              // "one" or "other"
        string template = LocalizationManager.Instance.Get(baseKey + "." + category);
        return string.Format(template, count);
    }
}

The table needs one row per category, not one row per language:

KEY en th item.count.one {0} item collected (not used -- th has no "one" form) item.count.other {0} items collected เก็บได้ {0} ชิ้น

Debug.Log(Pluralizer.FormattedCount("item.count", 1, "en"));
Debug.Log(Pluralizer.FormattedCount("item.count", 5, "en"));
Debug.Log(Pluralizer.FormattedCount("item.count", 5, "th"));

Expected output:


1 item collected
5 items collected
เก็บได้ 5 ชิ้น

Notice the Thai rule never looks at item.count.one at all — it always asks for item.count.other, because Thai grammar has nothing that corresponds to English's "one" case. A French rule would need yet another shape: French treats both 0 and 1 as its "one" category, unlike English, which only treats 1 that way — a fact you would only discover by checking, not by guessing from English (this exact rule is Exercise 2).

Gender causes a similar problem: some languages change a sentence's words depending on the grammatical gender of the subject — French "Il est prêt" (he is ready) versus "Elle est prête" (she is ready) are different strings, not one string with a swapped pronoun. When a sentence's translation genuinely depends on the player character's gender, studios add separate keys for each case ("ready.male", "ready.female") rather than trying to force one template to cover both — the same key-per-variant idea used for plurals, applied to a different kind of variation.

7. Dates, Numbers, and Currency: CultureInfo

Numbers are not written the same way everywhere either. English writes 1,234.50 — comma for thousands, period for the decimal point. German writes the same value as 1.234,50 — the two symbols swapped. Dates have the same problem: is 3/5/2026 March 5th or May 3rd? It depends entirely on which country's convention is being used. Manually building these strings with string concatenation would mean hand-coding a different rule per country — exactly the kind of problem .NET's CultureInfo (a built-in class describing a language and region's formatting conventions) already solves for you.


using System;
using System.Globalization;
using UnityEngine;

DateTime patchDate = new DateTime(2026, 3, 5);
double price = 1234.5;

CultureInfo en = CultureInfo.GetCultureInfo("en-US");
CultureInfo de = CultureInfo.GetCultureInfo("de-DE");

Debug.Log(patchDate.ToString("d", en));
Debug.Log(patchDate.ToString("d", de));
Debug.Log(price.ToString("N2", en));
Debug.Log(price.ToString("N2", de));
Debug.Log(price.ToString("C", en));
Debug.Log(price.ToString("C", de));

Expected output:


3/5/2026
05.03.2026
1,234.50
1.234,50
$1,234.50
1.234,50 €

Same DateTime, same double — only the CultureInfo passed in changed, and every symbol, separator, order, and currency mark followed automatically. You never manually insert a comma or decide where the currency symbol goes; CultureInfo already knows each region's rule.

Common mistake Using the player's display culture for numbers you save to a file or send over the network. If a German player's save file stores "1.234,50" and an English player's code tries to read it back with double.Parse using its own culture rules, the comma and period get misread and the value comes back wrong (or throws an exception). Always save and parse internal data with CultureInfo.InvariantCulture (a fixed, culture-neutral format), and reserve the player's actual culture for what gets displayed on screen.

8. Text Expansion: When Translated Text Does Not Fit

Text expansion is the fact that the same sentence takes very different amounts of space in different languages. German, Finnish, and Russian sentences are often 30-50% longer than their English source in character count. Thai is a different kind of problem: it is not always longer in character count, but Thai script stacks tone marks and vowel signs above and below the base letters, so a line of Thai text often needs noticeably more vertical space (line height) than a line of Latin text at the same font size, even when it is not wider.

A UI built and tested only in English, with pixel-exact button widths, breaks the moment real translations arrive:

English button (fits comfortably): +------------------+ | Start Game | +------------------+ German button (fixed width, same box): +------------------+ |Spiel starten | -- barely fits +------------------+ +------------------+ |Einstellung | <-- "Einstellungen" (Settings) got clipped, +------------------+ the final "en" is missing entirely A button sized in pixels for the English word was never going to survive a 40% longer German word.

The fix is a layout that adapts to its content instead of assuming a fixed size: a button whose width grows with its label (Unity's ContentSizeFitter and layout groups do this), text that shrinks its font size to fit a maximum box (TextMeshPro's auto-size feature), or a design that reserves generous empty margin around every label from the start instead of sizing boxes exactly to the English text.

Tip Test for this before real translations exist, using pseudo-localization (temporarily replacing every source string with a fake, padded version, like turning "Start Game" into "[[[ Ştärt Gämé ~~~ ]]]"). It is deliberately about 40% longer and uses accented characters, so any layout that cannot survive translation breaks immediately in testing, weeks before a translator ever sees the real text.

9. Fonts and Glyph Coverage

A font file does not contain every possible character — it contains a fixed set of glyphs (the actual drawn shapes for specific characters). A typical Latin font ships glyphs for the English alphabet plus accented European letters, maybe 200-300 shapes total, and renders text by looking each character up and drawing its glyph. If a character has no glyph in the active font, most engines fall back to a visible placeholder box — often called tofu — instead of the real character:

Text wants to show: 你好世界 Active font has no CJK glyphs, so each character falls back to an empty placeholder box instead of the real glyph: [ ] [ ] [ ] [ ] <-- "tofu": visible proof a glyph is missing

This creates a sizing problem specific to CJK (Chinese, Japanese, Korean — languages using thousands of distinct characters instead of an alphabet). A Latin font atlas (a single texture image packed with pre-rendered glyph bitmaps, sampled by the GPU to draw text cheaply) might need only a few hundred glyphs and fits comfortably on a small texture. Chinese alone has 3,000-9,000+ commonly used characters — far too many to pre-bake into one atlas up front without an enormous texture. Engines handle this either by generating glyphs into the atlas dynamically as they are actually needed (rendering and caching a glyph the first time that character appears on screen), or by shipping a separate, large CJK font asset that is only loaded when a CJK language is actually selected, instead of bundling it into every language's build.

Thai adds a different rendering challenge: tone marks and vowel signs are combining marks that stack above or below a base consonant rather than sitting in their own character slot next to it, and a base consonant can carry more than one mark at once. A renderer that naively places one glyph after another, left to right, without understanding how to position combining marks will draw Thai text with marks in the wrong place or overlapping incorrectly. Correctly stacking these marks is called text shaping (positioning and combining glyphs according to a script's own rules, not just placing them left to right in a row), and it needs a text rendering system built to support it — this is one reason Unity's TextMeshPro is generally preferred over the legacy UI Text component for any game shipping Thai, Arabic, or CJK text.


using TMPro;

// Real font-asset APIs vary by TextMeshPro version, but the idea
// is always the same: check whether a font actually has a glyph
// for a character before assuming it will render correctly.
public bool HasGlyphFor(TMP_FontAsset font, char c)
{
    return font.HasCharacter(c);
}
Tip Set up a fallback font list (a chain of backup fonts TextMeshPro tries, in order, whenever the primary font lacks a glyph). Your main UI font can stay small and Latin-only, while a Thai or CJK fallback font only gets used for the specific characters that need it — you do not have to make every font contain every language's glyphs.

10. Right-to-Left Languages and Mirrored Layouts

Arabic and Hebrew are right-to-left (RTL) languages: text reads and flows from right to left instead of left to right. This affects far more than which way sentences read — a UI built for a left-to-right language usually needs its whole layout mirrored, not just its text alignment.

LTR layout (English, Thai, Japanese, ...): +----------------------------------------+ | [Back] [Title] [X] | | Menu items start on the left, | | text flows left to right | +----------------------------------------+ RTL layout (Arabic, Hebrew) -- the WHOLE layout mirrors, not just the text: +----------------------------------------+ | [X] [Title] [Back] | | text flows right to left, | | menu items start on the right | +----------------------------------------+

A "back" arrow that points left in the LTR layout should point right in the mirrored RTL layout, because it still needs to mean "go to the previous, earlier screen" — the meaning stays the same even though the icon flips. Progress bars, health bars, and swipe directions typically mirror too, so the game still feels natural to a reader whose eyes move right to left across the screen.

Not everything mirrors, though, and getting this wrong looks just as broken as not mirroring at all. Numerals inside Arabic text are still written left-to-right even though the surrounding words read right-to-left — mixing both directions in one line is called bidirectional text ("bidi" for short), and it needs a text rendering system that understands the rule rather than blindly reversing every character. Photographs, a clock face, a video, and anything with a real-world physical meaning should never be flipped — only the abstract layout chrome (icons, alignment, navigation direction) mirrors. Company logos never mirror either.

Tip At this stage you only need the concept: know that shipping Arabic or Hebrew is not just "translate the strings," it is a layout decision that has to be designed for from the start — anchoring UI elements relatively (left/right based on reading direction) instead of with fixed absolute X positions makes a later RTL pass far less painful, even if you are not building RTL support yet.

11. Voice-Over and Localized Assets

Text is not the only thing that gets localized. Voice-over (VO) — the recorded spoken dialogue — usually has its own, separate language setting, chosen independently from the text language. This is exactly why a big game lets you play with, say, Thai on-screen text and Japanese voice acting at the same time: the two systems are not the same lookup.


using UnityEngine;
using System.Collections.Generic;

public class LocalizationManager : MonoBehaviour
{
    public static LocalizationManager Instance { get; private set; }

    public string currentTextLanguage = "en";
    public string currentVoiceLanguage = "ja";

    private Dictionary<string, Dictionary<string, AudioClip>> voiceTable;

    public AudioClip GetVoiceClip(string voiceKey)
    {
        if (voiceTable.TryGetValue(voiceKey, out var perLanguage) &&
            perLanguage.TryGetValue(currentVoiceLanguage, out var clip))
        {
            return clip;
        }
        return null; // fall back to no audio, or a default-language clip
    }
}

The shape is identical to the string table from Section 3 — a key, and one value per language — except the value is an AudioClip instead of a string. The practical difference is size: audio files are large, and multiplying every line of dialogue by twelve languages produces an enormous amount of data. Most large games solve this by shipping only the chosen voice language's audio (downloaded or installed on demand) instead of bundling all twelve voice packs into every player's install.

Localized textures are the other common non-text asset: an image that has words baked directly into its pixels — a shop sign, an in-game book page rendered as art, a tutorial screenshot with English callouts drawn on top. A string table cannot fix these, because the words are not characters at all, just colored pixels. Each language needs its own separate texture, swapped by key and current language the same way a voice clip is.

Tip Avoid baking real text into art whenever you can — render actual text on top of a plain background instead. A texture with words painted into it multiplies your art team's workload by the number of languages you support; a real text label on top of the same background costs nothing extra per language, because it goes through the string table like everything else.

12. The Localization Pipeline

Everything so far assumed the string table already exists. In production, filling it in is its own workflow, called the localization pipeline: the path strings travel from a programmer writing a key, to a translator writing the words, to those words appearing in the shipped game.

Master table (English, the "source of truth") | v Export tool --> strings.csv (columns: key, en, th, ja, de, ...) | v Translators fill in each language's column (they never touch code, never open the game project) | v Import tool --> per-language tables the game actually loads | v LocalizationManager.Get(key) returns the right text at runtime

Translators work in a spreadsheet or a dedicated translation tool, never in the codebase. A simple export step walks every key in the master table and writes it out as one row per key, with the source language already filled in and every other language's column left blank for a translator to fill:


using System.IO;
using System.Text;
using System.Collections.Generic;

public static void ExportForTranslators(Dictionary<string, string> sourceTable, string path)
{
    var sb = new StringBuilder();
    sb.AppendLine("key,en,th,ja"); // header row: key, then one column per language

    foreach (var pair in sourceTable)
    {
        // Source language is filled in already; translated columns start blank.
        sb.AppendLine(pair.Key + "," + pair.Value + ",,");
    }

    File.WriteAllText(path, sb.ToString());
}

Once translators fill in their columns and send the file back, an import step reads it and rebuilds the per-language dictionaries LocalizationManager loads. Two rules keep this pipeline from breaking down as a project grows: never rename a key once translators have started working from it (a rename looks like "delete one key, add a new empty one" to the pipeline, throwing away the translation that was already done), and give translators context beyond the bare English text — a short comment describing where a string appears, or a maximum character limit, because the same English word ("Level", for instance) can need a completely different translation depending on whether it means a stage in a dungeon or the act of gaining experience.

Tip Unity ships a built-in Localization package (under Window, Asset Management) that implements this entire pipeline for you — String Tables, CSV/Google Sheets import and export, and Smart Strings with built-in plural and gender support. It is worth using in a real project instead of hand-rolling the CSV code above. The underlying idea, though, is exactly the key-and-table model taught in this chapter, no matter which tool ends up implementing it.

13. Glossary

14. Exercises

Exercise 1 — Fix the Concatenation The method below builds a damage-report sentence by gluing three pieces together with +. Rewrite it to use a key and a format string with a placeholder instead, the way Section 5 describes. Assume LocalizationManager.Instance.Get("combat.damage_dealt") returns the template for the current language.

void ShowDamage(string attackerName, int amount)
{
    // BAD: word order is hard-coded, cannot be fixed by translation alone.
    label.text = attackerName + " dealt " + amount + " damage!";
}
Show answer

// Table entry (English): "combat.damage_dealt" -> "{0} dealt {1} damage!"
void ShowDamage(string attackerName, int amount)
{
    string template = LocalizationManager.Instance.Get("combat.damage_dealt");
    label.text = string.Format(template, attackerName, amount);
}

ShowDamage("Slime", 12);

Expected output (English active): Slime dealt 12 damage!. Because the whole sentence, including the order of attackerName and amount, now lives inside the translated template, a Japanese translator is free to write a template that places the number before the name, or anywhere else Japanese grammar requires — the C# code itself never changes again, no matter how many languages are added.

Exercise 2 — Add a French Plural Rule French plurals do not follow English's rule. In French, both 0 and 1 use the "one" form ("0 pomme", "1 pomme" — no "s"), and everything else uses "other" ("2 pommes"). Using the Pluralizer class from Section 6 as a starting point, add a French rule to the rules dictionary under the key "fr", then show what Pluralizer.FormattedCount("item.count", 0, "fr") and Pluralizer.FormattedCount("item.count", 2, "fr") would print, assuming the table has item.count.one = "{0} pomme" and item.count.other = "{0} pommes".
Show answer

private static readonly Dictionary<string, Func<int, string>> rules =
    new Dictionary<string, Func<int, string>>
{
    { "en", n => n == 1 ? "one" : "other" },
    { "th", n => "other" },
    { "fr", n => (n == 0 || n == 1) ? "one" : "other" }, // French: 0 AND 1 both count as "one"
};

Debug.Log(Pluralizer.FormattedCount("item.count", 0, "fr"));
Debug.Log(Pluralizer.FormattedCount("item.count", 2, "fr"));

Expected output:


0 pomme
2 pommes

The English rule would have sent count == 0 to the "other" category (since only 1 is special-cased), which is exactly wrong for French. This is why plural handling has to be written per language using each language's actual rule, never assumed from whichever language the programmer happens to speak.

Exercise 3 — Spot and Fix the Layout Trap The Inspector-configured button below has its width hard-coded to a fixed pixel value, sized to fit the English word "Settings" exactly. Explain in one or two sentences what will happen when this button's label is switched to German ("Einstellungen") or Thai, and describe (no full implementation needed) what you would change about this GameObject's setup to fix it, referencing Section 8.

using UnityEngine;

public class SettingsButtonSetup : MonoBehaviour
{
    void Awake()
    {
        // BAD: width is a fixed number of pixels, sized for the English word.
        GetComponent<RectTransform>().sizeDelta = new Vector2(90f, 30f);
    }
}
Show answer

"Settings" is 8 characters and fits in 90 pixels, but "Einstellungen" is 13 characters — German text expansion (Section 8) means the translated label will overflow this fixed width and either get clipped or spill outside the button's edges. Thai has the opposite-looking but related problem: even if the word itself is not much wider, Thai's stacked tone marks and vowel signs need more vertical space, so a fixed 30f height sized for plain Latin capitals can visually crowd or clip Thai text at the top and bottom.

The fix is to stop hard-coding sizeDelta at all. Add a ContentSizeFitter set to resize horizontally based on the label's preferred width (so the button grows to fit whatever word ends up in it), keep a minimum width for tap-target comfort on touch devices, and either give the text component enough vertical padding by default or enable TextMeshPro's auto-size so a label that is unexpectedly tall in Thai shrinks its font slightly instead of clipping. None of this requires knowing the final translated text in advance — the whole point is that the layout adapts to whatever text the table hands it.

← Back to all chapters