Engineeringこの記事を日本語で読む

Localizing a game with no engine localization support

Not every game is built on an engine with a ready-made localization system. Custom engines, small in-house frameworks, and plenty of well-established commercial games built before their engine had first-party localization tooling all end up in the same position: rolling your own. The good news is that the core mechanism is not complicated — a key/value lookup with a fallback language — and the same design decisions apply whether you are writing it in an afternoon for a game jam or building something meant to last for years of live-service updates.

This article is a design checklist for that system: how to structure it, what to get right early because it is painful to retrofit later, and the specific traps that catch home-grown localization systems that a mature engine's tooling would have caught for you automatically.

The core: a key/value store and a lookup function

At minimum, you need a data structure mapping a key to a translated string per locale, and a function every piece of player-facing text goes through instead of a raw string literal. The function signature is worth getting right early, since every call site in your codebase will depend on it:

function t(key: string, params?: Record<string, string | number>): string {
  const table = translations[currentLocale] ?? translations[fallbackLocale];
  const template = table[key] ?? translations[fallbackLocale][key] ?? key;
  return interpolate(template, params);
}

Choosing a file format

JSON, CSV, and YAML are the common choices, and the right one depends on who edits the file and how. JSON nests naturally and is easy to load with no extra parsing code, but is unforgiving to hand-edit and gives translators no visual sense of which language they are looking at relative to others. CSV opens cleanly in a spreadsheet, which is often exactly what a translator wants, and makes missing-translation gaps visible as empty cells in a column — but flattens naturally nested key structures and needs care around commas, quotes, and line breaks inside cells. YAML is more human-editable than JSON while keeping structure, at the cost of being more sensitive to whitespace errors.

Whichever you choose, keep exactly one file (or one file per locale, never a mix) as the source of truth, and never let a key exist in code without existing in that file — a missing key should be something your tooling catches before a translator or a player ever encounters it.

Fallback language is not optional

A lookup that fails silently when a key is missing for the active locale — rendering a blank string, or worse, crashing — is the single most common failure mode in home-grown localization systems. Always fall back to a known-complete language (usually your source language) when a translation is missing, so a gap in coverage degrades to temporarily untranslated text rather than a broken or empty UI element. Never let the fallback itself be silent, either — log or flag missing keys somewhere your team actually looks, so gaps get filled instead of quietly persisting.

Placeholders and interpolation

Any text built from parts needs a placeholder syntax and an interpolation step, and this is worth designing deliberately rather than reaching for raw string concatenation the first time a value needs to be inserted. Named placeholders (`{playerName}`, `{damage}`) rather than positional ones (`%s`, `%d`) let a translator reorder them to fit their language's word order without needing to also track a numeric position, which is both easier to get right and easier to validate automatically.

Plurals need explicit handling

A naive approach — one string with a hardcoded `(s)` for English plurals — does not generalize to languages with more plural categories than English's simple singular/plural split. Building this correctly means designing your key structure to support multiple plural forms per concept from the start (however many your target languages need) rather than bolting it on once a translator flags that a language's plural rule cannot be expressed in your current format.

Hot-reloading during development

If your locale files can be reloaded without restarting the game — watching the file on disk and reparsing it, or a debug command that reloads the current locale — the loop of tweaking a string and seeing it in context gets dramatically faster, which matters more than it sounds like it should over the life of a project. This is worth building early rather than treating as a nice-to-have, since the cost of adding it later is the same, but you pay the slow loop for however long you go without it.

Traps that catch home-grown systems specifically

These are the failure modes a mature engine's localization tooling tends to prevent by construction, and that a hand-rolled system has to guard against deliberately:

  • Missing-key silence — a lookup miss renders nothing or crashes instead of falling back and flagging the gap
  • String concatenation instead of placeholders — building a sentence from `"You found " + count + " items"` bakes in source-language word order and breaks for any language with different syntax
  • No context for translators — a flat key/value file with no notes, screenshots, or character/screen metadata forces translators to guess, and guesses are wrong often enough to matter at scale
  • No validation step — nothing checks that every locale has every key, that placeholder tokens match between source and translation, or that a key referenced in code actually exists in the data
  • Text treated as an afterthought in the render layer — hardcoding a string directly in a UI component because it was faster in the moment, which then has to be found and migrated later by hand

The underlying principle

None of this is exotic — it is the same discipline a mature engine's localization system enforces for you, made explicit because nothing is enforcing it here. Treat text the same way you would treat any other data your game depends on: one source of truth, a schema you validate against, and a lookup path with a defined behavior for the case where something is missing, rather than an accident of what happens to render when a string is not found.

Related articles