The Complete Guide to i18n in a Cocos Web Game (Without a Rewrite)
TL;DR: Internationalization in a Cocos Creator 3.8 web / WeChat mini-game isn't a find-and-replace pass — it's an architecture decision. I made my originally Chinese-only build portable by routing every visible string through one localization layer, estimating label width per character class (CJK vs Latin) so layouts don't collapse, and pushing structured flags into data instead of baking them into names. Do it in week one, or you pay for it in a rewrite.
1. Why i18n is different on a game runtime
Web apps get i18n almost for free: the DOM reflows, CSS handles width, and libraries like i18next plug into the framework. A game runtime gives you none of that. In Cocos, text lives in Label components with fixed or estimated sizes, and a short Chinese label like 「装备」 can become a long English sentence — "Equipped" — that overflows the very cell you laid out by hand.
I learned this the hard way. My first build had Chinese strings hardcoded across panels. The moment I wanted an English build, I faced either a painful grep-and-replace or a proper seam. I chose the seam, and this guide is the result.
If you're weighing engines first, the i18n story is part of the bigger picture I covered in my Cocos vs Unity vs Godot breakdown.
2. The core idea: text as data, not as literals
Step one is boring but non-negotiable: no visible string may live in code or in a prefab's text field. Everything routes through a localization table keyed by a stable id.
// locales/en.json
{
"item.equipped": "Equipped",
"item.locked": "Locked",
"battle.flee": "Flee",
"bag.title": "Backpack ({cur}/{max})"
}
// locales/zh.json
{
"item.equipped": "已装备",
"item.locked": "未解锁",
"battle.flee": "逃跑",
"bag.title": "背包({cur}/{max})"
}
A single tr(key, params) helper resolves the active locale and fills placeholders. Components subscribe to a locale-change event and re-render their own text. That's the whole foundation — every panel now talks to the table, never to a literal.
This is the same discipline I described in my Cocos mini-game post-mortem: build UI as code, and treat strings as just another data field.
3. The part most guides skip: width estimation
Reflowing game text is the real pain. The browser does it for free; Cocos does not. A label sized for 「已装备」 (3 CJK glyphs ≈ 3× font size) will clip "Equipped" if you assumed the same width. I solved this with a shared textMetrics helper that estimates label width from character class:
// ui/textMetrics.ts — rough width estimate per char class
function charUnits(ch: string): number {
const code = ch.codePointAt(0)!;
// CJK ranges: 0x2E80–0x9FFF, 0x3000–0x30FF, 0xFF00–0xFFEF
if (code >= 0x2E80 && code <= 0x9FFF) return 1.0; // full-width
if (code >= 0x3000 && code <= 0x30FF) return 1.0; // kana / full-width punctuation
if (code >= 0xFF00 && code <= 0xFFEF) return 1.0; // full-width forms
return 0.55; // latin / narrow
}
export function estimateTextWidth(text: string, fontSize: number): number {
let units = 0;
for (const ch of text) units += charUnits(ch);
return units * fontSize;
}
With that, a parent container can size itself from the estimated width of the active language before the engine's own text layout runs. It's not pixel-perfect (the engine's assembler is the source of truth at render time), but it gets panels into the right ballpark so nothing overflows on first paint. When a label has no fixed width, I leave it auto-sized; when it does, I recompute position from the estimated width rather than assuming the Chinese baseline.
4. Structured fields beat concatenated strings
A tempting shortcut is to bake state into the name: "[Equipped] Iron Sword". Don't. It forces every locale to re-express the bracket convention and breaks layout symmetry. Instead, push the flag into data:
interface GridCellData {
id: string;
name: string; // already localized via tr()
isEquipped: boolean; // rendered as a separate badge, not in the name
subText?: string;
}
The badge is a separate node positioned by the layout, the name stays clean, and translations never have to invent a "[已装备]" convention. This single rule probably saved me more rework than anything else.
5. Pitfalls I hit
- Numbers and plurals. "1 item" vs "3 items" — wrap counts through a pluralization helper per locale, don't concatenate "item(s)".
- Right-to-left wasn't my case, but width still was. Even left-to-right Latin text is wider than CJK for the same meaning. Size containers from the widest expected locale, not the source one.
- Font atlas. Make sure your bitmap/signed-distance-field font actually contains the target glyphs. Missing glyphs render as empty boxes and you'll swear the localization failed when it's really a font-pack problem.
- Save data. Never store the translated string; store the key. If you persist "Equipped" into a save file and later rename the English copy, old saves break. Keys are stable; copies change.
6. Takeaways
If you're starting a Cocos web or mini-game and think you might ever ship outside your home market: pour the i18n seam in week one. One localization table, a width estimator keyed on character class, and structured fields instead of concatenated labels. The game below the text is identical everywhere — only the words and the width of the box change. Build the seam early and the "overseas version" becomes a config switch, not a second project.