All posts

Why localisation breaks when you rewrite one line, and how stable string IDs prevent it

Almost every localisation misalignment comes from IDs that are derived from something that changes. The fix is unglamorous and has to be decided before the first string table ships.

Published

The bug looks like this. You send a string table to translators. Two weeks later you rewrite a conversation, regenerate the export, and ship. In the German build, a character answers a question nobody asked, and three lines later someone says goodbye in the middle of a scene. In English everything is fine.

Nobody notices for a while, because noticing requires playing the game in a language you do not read.

The cause is always the same

Localisation breaks when a string’s identity is derived from something that changes — its position in a file, a hash of its text, or an index into an array. Rewrite one line and every ID after it shifts, so translated text stays attached to IDs that now point at different English. The fix is to allocate an ID once, at the moment a line is first written, and never derive it from anything again — after that, a line can move, be rewritten, or be deleted without disturbing any other line’s identity.

The three derivations all look reasonable when you write them:

  • Positional (dialogue_042, line_17) — obvious, readable, and wrong the first time someone inserts a line in the middle.
  • Content hash — appealing because it is deterministic, and it is exactly backwards: fixing a typo changes the ID, which is precisely when you least want to lose the translation.
  • Structural path (quest.scene.node.3) — survives typo fixes, breaks when the scene is reorganised, which happens more often than typo fixes.

An allocated ID has none of these properties, because it has no properties. It is a name.

What allocation costs you

Someone has to own the allocation, and IDs have to persist across regenerations. That means an authority — a string table that is written to, not derived — and the discipline of never recycling an ID for a different line, even after deleting the original.

You also give up readable IDs, which is a real loss when reading a diff. It is worth it. A readable ID that lies is worse than an opaque one that does not.

The check that actually saves you

Allocation alone is not enough, because the failure that hurts is not “an ID changed”, it is “an ID changed and nobody noticed”. So the export has to compare against the last string table it produced and refuse to write if IDs would move unexpectedly.

Refuse, not warn. A warning during a build is a line of text nobody reads at the end of a successful command, and the whole failure mode of this bug is that it is invisible until it is expensive. Ours aborts the export and says which IDs would have changed.

If you are retrofitting this

You cannot recover identity that was never recorded, so pick a point and freeze it: take the current string table, allocate a stable ID for every line in it, and store that mapping as the authority from now on. Everything before the freeze is reconciled by hand once. Everything after is safe.

The reason to do it before shipping the first string table for translation is arithmetic: the cost of the freeze is proportional to how many lines exist when you do it, and the cost of not doing it is proportional to how many languages you have shipped.