npx skills add ...
npx skills add unity-technologies/skills --skill localization
Sets up and configures Unity Localization, including locales, String/Asset Tables, CJK font support, and Addressables workflows. Use when the user wants to add languages to a project, translate UI text, support Asian (CJK) languages with TMP fonts, or mentions i18n, l10n, multilingual support, or making a game support multiple languages.
npx skills add unity-technologies/skills --skill localization
This guide covers setting up and configuring Unity Localization, including locales, String and Asset Tables, Addressables integration, and CJK font support via Asset Tables.
Before doing anything else, verify that the Localization package is installed. Many APIs in this skill will fail silently or throw confusing errors if the package isn't present.
com.unity.localization in Packages/packages-lock.json. That file records what Unity
actually resolved, it is plain JSON, and reading it needs no Editor and no async call.
(Packages/manifest.json only records what was requested, so check the lock file.)UnityEditor.PackageManager.Client.Add("com.unity.localization").Client.Add and Client.List are asynchronous: they return a request that
is still InProgress when the call returns, so reading the result in the same statement tells you
nothing. Do not busy-wait on IsCompleted either; that blocks the main thread you are running on.
Instead, return after firing the install, then poll packages-lock.json in a later call until
the id appears. Installation also triggers a domain reload, so expect the first poll or two to
fail; a fresh install typically resolves in a few seconds.ready.If LocalizationEditorSettings.ActiveLocalizationSettings is null, you must find or create it:
AssetDatabase.FindAssets("t:LocalizationSettings", new[] { "Assets" }). If found, load the first one and assign it to LocalizationEditorSettings.ActiveLocalizationSettings.
FindAssets searches the whole project including
read-only packages, so it can return an asset from a package and you end up pointing the project
at something you cannot edit. This applies to every FindAssets call in this skill.Assets/Localization/LocalizationSettings.asset. Use ScriptableObject.CreateInstance<LocalizationSettings>() followed by AssetDatabase.CreateAsset().LocalizationEditorSettings.ActiveLocalizationSettings = settings.LocalizationEditorSettings.AddLocale(locale).Programmatic changes to String or Asset tables require notification to the Editor. Always create the required asset tables, unless there is already an existing one in the project.
When populating tables from a dataset, match by Locale.Identifier.Code explicitly. The order of GetLocales() is not guaranteed to match your input data array — assuming it does will cause silent data mismatches that are very hard to debug.
For Asset Tables, use the GUID of the asset: table.GetEntry(sharedId) ?? table.AddEntry(sharedId, guid);.
After any modification (adding keys, updating values), notify the Editor so it can refresh its internal state. Skipping this will leave the Editor showing stale data until the next reimport.
EditorUtility.SetDirty(collection), EditorUtility.SetDirty(collection.SharedData), on each modified Table.LocalizationEditorSettings.EditorEvents.RaiseCollectionModified(sender, collection);AssetDatabase.SaveAssets() at the end.UnityEngine.UI.Image, UnityEngine.UI.VerticalLayoutGroup, UnityEngine.UI.ScrollRect, UnityEngine.UI.Mask, UnityEngine.UI.CanvasScaler, UnityEngine.UI.GraphicRaycaster, UnityEngine.UI.ContentSizeFitter, UnityEngine.UI.LayoutRebuilder, etc.UnityEngine.UI is both a namespace and a class container, so unqualified names produce CS0118 (namespace used like a type). Full qualification avoids this entirely.GameObject.Find("YourCanvasName") and destroy the old one before creating a new one.Window > Asset Management > Localization Scene Controls). This is Editor-only. It is not a
runtime feature, so it is not the answer when the game itself needs a language setting.LocalizationSettings.SelectedLocale. That is the
supported entry point, and everything bound through LocalizeStringEvent updates from it.SpecificLocaleSelector is the one that forces a chosen locale;
the default chain otherwise picks up the system language.SelectedLocale and lets the package propagate the change. What is forbidden is a debug
dropdown or menu that tracks its own "current language" variable, swaps strings itself, or
reaches around the package, because nothing else in the project will follow it.Check Component Type: Identify if the target is TextMeshPro or legacy UnityEngine.UI.Text.
Bind Correctly: add the public UnityEngine.Localization.Components.LocalizeStringEvent
component and wire it yourself — set StringReference to the table entry, then add an
OnUpdateString listener that assigns the value to the text component (TMP_Text.text for
TextMeshPro, UnityEngine.UI.Text.text for legacy Text).
Do not reflect into UnityEditor.Localization.Plugins.TMPro.LocalizeComponent_TMPro or its
UGUI counterpart. Those are internal (measured on Localization 1.5.12), so reaching them means
routing around access control to reach an API Unity makes no stability commitment about — it can
change or disappear in any package release. LocalizeStringEvent is public and does the same job
with the wiring made explicit.
Layout Rebuild: After setting localized text or populating a list, call UnityEngine.UI.LayoutRebuilder.ForceRebuildLayoutImmediate(parentTransform) to ensure dimensions update.
Avoid TMP Fallback Fonts for CJK locales. Use Asset Table Font Swapping for each specific locale instead — fallbacks are unreliable and hard to debug when glyphs are missing.
Check this before touching any TMP API. In a project that has never imported them,
TMP_Settings.instance is null and TMP calls fail with a bare
NullReferenceException that names nothing useful. TMP_FontAsset.CreateFontAsset is one of them, so
font creation dies on the first line with an error that looks like a bug in your code.
If it is not ready, import them non-interactively:
Then poll TMP_Settings.instance != null in a later call, the same way as the package check in
Step 0, and only continue once it is non-null. Verified on Unity 6000.5.8f1: the non-interactive
import completes in a few seconds and the assets land in Assets/TextMesh Pro.
msyh.ttc (Microsoft YaHei) or equivalent.msgothic.ttc (MS Gothic) or equivalent.malgun.ttf (Malgun Gothic) or equivalent.TMP_FontAsset from imported fonts.fontAsset.atlasPopulationMode = AtlasPopulationMode.Dynamic;fontAsset.isMultiAtlasTexturesEnabled = true;Material objects on disk, and one with it.
A material that exists only in memory is not part of the asset, so anything that loads the asset
fresh gets whatever TMP reconstructs rather than the material you configured, and any setting you
applied to it is silently gone.
fontAsset.material.mainTexture = fontAsset.atlasTexture;
and set the font asset, its material, and its textures dirty before saving. (atlasTexture is
the first entry of atlasTextures, which is what the primary material draws from, so this is
consistent with adding every texture above.)AssetDatabase.SaveAssets(), call AssetDatabase.LoadAllAssetsAtPath(path) and confirm a
Material is among the returned objects. Do not settle for fontAsset.material != null: that
stays true whether or not the material was saved, because TMP will hand back an in-memory one,
so it cannot tell a saved material from an unsaved one.Resources/ folder in an Asset Table. This causes OperationException: Failed to load sub-asset errors. If an asset is in Resources/, copy it to Assets/Fonts/ or similar before making it Addressable.LocalizedTmpFont over LocalizedAsset<TMP_FontAsset> to avoid implicit conversion errors.AddressableAssetSettings.BuildPlayerContent();.Before concluding any CJK localization task:
zh-Hans, ja, and ko. Inspect the UI. If any characters appear as squares (tofu), the font setup has FAILED.AssetTable for the CJK locale points to the correct CJK TMP_FontAsset, NOT a default Western font.isMultiAtlasTexturesEnabled is true on the CJK font assets.VerticalLayoutGroup with Child Control Height: True, Child Force Expand Height: False.ContentSizeFitter set to Vertical Fit: Preferred Size.Enable Word Wrapping: True and Overflow: Overflow.L10n) to wrap LocalizationSettings.StringDatabase.GetLocalizedString for easy injection into existing scripts.LocalizeStringEvent.OnUpdateString with
UnityEventTools.AddPersistentListener, passing a delegate built over the text component's public
text setter. The setter has no C# method-group name, so build the delegate by name:
(UnityAction<string>)Delegate.CreateDelegate(typeof(UnityAction<string>), text, "set_text").
That is reflection over a public member, which is fine. See
resources/L10nBatchProcessor.cs for the working version, including
clearing any existing persistent listeners first so repeated runs don't stack duplicates.
Do not write the persistent-call fields directly through SerializedObject (m_MethodName,
m_Mode, m_PersistentCalls). Those are private serialized names with no compatibility
guarantee, and it isn't necessary: AddPersistentListener with the delegate above produces the
same serialized call (target = the text component, method = set_text, mode = EventDefined).
You must then set the call state, or the label will not update in the Editor.
AddPersistentListener leaves the call at UnityEventCallState.RuntimeOnly, so the binding is
correct but dormant outside Play mode: switching locale in the Editor changes nothing, and it
stays that way through a save and reload. Fix it with the public
UnityEventBase.SetPersistentListenerState:
Verified on Unity 6000.5.8f1: without the second call the listener does not fire in Edit mode
even after a prefab save and reload; with it the call state becomes EditorAndRuntime and the
text updates immediately.
Persistent listeners MUST point to a method on a UnityEngine.Object; lambdas will fail.
Then confirm the binding is live, don't assume it. Wiring that looks right in the Inspector but does nothing is the characteristic failure of this step. All of the read-back you need is public API on the event, so none of this requires touching serialized fields:
| Check | Call | Expect |
|---|---|---|
| Something was wired | GetPersistentEventCount() | > 0 |
| It points at the text component | GetPersistentTarget(i), GetPersistentMethodName(i) | the component, set_text |
| It will fire while authoring | GetPersistentListenerState(i) | EditorAndRuntime |
| It actually updates the label | lEvent.RefreshString() | the text value changes |
Do all four. A count above zero only proves something was wired, and a RuntimeOnly call fails
the last check while being perfectly correct for a build, so reading the state is what tells a
dormant binding apart from a broken one. A component that was added and configured but never
fires is worse than an unlocalized label, because it reads as done.
LocalizationEditorSettings.CreateStringTableCollection expects a directory path (e.g., Assets/Localization), not a full asset path.lEvent.RefreshString() after assigning a LocalizedString reference programmatically to update the UI immediately.using System.Linq; when searching collections and using UnityEngine.Localization; when working with locales or tables.AddressableAssetSettings.BuildPlayerContent() and switch the Editor locale to verify changes. Check LocalizationSettings.Instance status after activation.Enumerating the tables answers "did every key get a value in every locale" mechanically, so a missing entry is found before anyone plays the game. Run it and report the output.
Verified on Unity 6000.5.8f1 against a table with one deliberately emptied ja value: it reports
GAPS 1 of 4 naming exactly that entry, COMPLETE (4 checked) once the value is filled, and
INCONCLUSIVE in a project with no tables.
Report the gap list rather than resolving it silently. Some gaps are decisions, not mistakes: a locale you were not asked to translate, or a key that is intentionally identical across languages. Filling those with the English text hides the decision. List them and let the user say which are intentional.
To efficiently translate an existing project, follow this multi-step workflow:
Extraction & Component Setup:
UnityEngine.UI.Text and
TextMeshPro (TMP_Text, the base of TextMeshProUGUI and TextMeshPro). Walk both
families. Measured on a real project: FindObjectsByType<Text> found 1 component while
FindObjectsByType<TMP_Text> found 13, so a legacy-only pass reports success having done
almost nothing.scoreLabel.text = $"EXP {value}" is invisible to every component-based scan and is the
string that survives a "finished" localization pass. Find it in the C# instead:
+= forms plus SetText, and
deliberately does not match label.text = someVariable or label.text = Localize("KEY")
(nothing to extract at the first, already routed at the second). Its blind spot is a literal
held in a variable or const declared elsewhere; if the count looks low for the project, grep
that file's string literals too.UIStrings) with the base language and a "Context" column for each key to guide translators.LocalizeStringEvent (for text) and a LocalizedFont helper (for font swapping).EditorAndRuntime) so they update in the Editor immediately when the locale changes.Context-Aware Translation:
Quality Assurance (QA):
Window > Asset Management > Localization Scene Controls or script: LocalizationSettings.SelectedLocale = LocalizationSettings.AvailableLocales.GetLocale("de");.ContentSizeFitter if strings are too long.For detailed API usage, common namespace conflicts, Addressables patterns, and font repair steps, see references/api-notes.md.
To localize an entire project efficiently, use a batch processing script that handles all scenes in one pass.
Ask before acting: Before running any batch operation, confirm with the user:
"This will open every scene in the project, attach
LocalizeStringEventcomponents, and save all modified scenes. This cannot be undone automatically. Shall I proceed?"
Only proceed once the user has confirmed. The batch processor template is in resources/L10nBatchProcessor.cs.
It walks both Text and TMP_Text, and LocalizeAll returns the labels it could not match
(as scene :: object :: "text"). Print that list. It is the whole point of the return value: a run
that wires 20 labels and silently leaves 9 alone looks identical to a complete one otherwise. The
list covers authored text only, so pair it with the code scan in Section 6 and the table completeness
check in Section 5.
TableReference names (strings) instead of GUIDs — they are easier to read and maintain.LocalizationSettings.Instance.ForceRefresh() after modifications to force the UI to update in the editor.GameAssets table once and use a script to re-assign LocalizeFontEvent to all labels in one pass.