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.
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.
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.
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."
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.
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).
!!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.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);
}
}
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.
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:
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.
+, 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.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:
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.
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.
"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.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:
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.
"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.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:
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);
}
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.
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.
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.
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.
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.
"menu.start") that code uses to ask for text; never shown to a player."You found {0}!" where {0} is replaced with a value at runtime, keeping the whole sentence intact for translation.+. 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!";
}
// 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.
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".
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.
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);
}
}
"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.