Skip to content

Internationalization in Godot

This note covers what we did for Skeleseller (which used C#) to internationalize/localize the game. Godot has documentation on the general process here.

For translation, the high-level plan was to use machine translations as a foundation and community contributions to improve the machine translations. Note: the languages to target, at least in 2025, seem to be: - French
- Italian
- German
- Spanish
- Simplified Chinese
- Japanese
- Korean
- Brazilian Portuguese

At a high level, you need to perform these steps:

Finding strings in your codebase that should be translated

Section titled “Finding strings in your codebase that should be translated”

We used xgettext for this. For C#, xgettext can only find function calls with string literals:

  • Translate("hi")
  • Translate(someString)
    • This won’t be found even if someString definitely points to a string.

As such, it’s best to wrap every translated string in some set of known function calls (like Translate or TranslatePlural). However, you can pass --extract-all to xgettext to have it extract every string from your files. This would include strings like Color.FromHtml("c70000") which don’t need to be localized. If you want to prevent finding those, you could make xgettext only search certain files/directories and consolidate all of your strings there.

Here’s the documentation for xgettext. It’s quite helpful and has some examples.

We run xgettext with the following arguments:

  • “—keyword=TranslationServer.Translate:1”
  • “—keyword=TranslationServer.Translate:1,2c”
  • “—keyword=Translate:1”
  • “—keyword=Translate:1,2c”
  • “—keyword=TranslatePlural:1,2”
  • “—keyword=TranslatePlural:1,2,4c”
  • “—keyword=Tr:1”
  • “—keyword=Tr:1,2c”
  • “—keyword=TrN:1,2”
  • “—keyword=TrN:1,2,4c”
  • “—from-code=UTF-8”
  • “—language=C#”
  • “—add-comments=I18N”
  • “—sort-by-file”
  • “—force-po”
  • “—files-from=/path/to/a_file_with_all_filepaths_in_it_on_separate_lines.txt”
  • “—output=/path/to/a_pot_file.pot”

Explanations:

  • The --keyword arguments are for finding calls in the source C# code. The “c” that’s appended to some of the numbers indicates the context argument. For example, Translate:1,2c will match Translate("Hello", "Greetings") in C# and emit it to the .pot file.
    • The context can be helpful if you have many strings that are the same in English but may not be the same in other languages. For example, you may have an ability called Boulder and an item called Boulder. The context could be set to "Abilities" or "Items" appropriately.
  • --add-comments=I18N means that comments in C# that start with I18N which immediately precede a translation call will make it to the .pot file. This can be helpful if there’s any context you want to add, e.g. // I18N This should be a short string.
  • We do not use --join-existing so that the .pot file will be generated anew every time we run xgettext. This is so that comments don’t accumulate in the file, e.g. due to changing the line number of a translated string in C# and then calling xgettext again.
    • The .po files themselves are specific to each language and will maintain any comments starting with # in them even across xgettext generations. By using these comments, we can mark metadata about a string like whether it was translated by a machine or a human.

Azure and Google Cloud Platform have very permissive translation services:

Azure’s allows 2 million characters per month for free. They even allow you to train a custom model, but that won’t work for indie game devs since it requires ten thousand training sentences (reference).

I.e. using these services are basically like going to Google Translate, typing in text, and getting the output in another language. It’s not going to let you provide context or do any meaningful training (unless you already have ~10k sentences, which is doubtful).

Originally, I wanted to write some custom, in-game code to allow people to see string identifiers and have a way to suggest new strings. Everyone said I over-engineered the solution, and I agreed, so I ended up using Weblate. It’s a self-hostable tool that lets you get translations from the community.

There are a few cases to accommodate:

Godot has built-in functions for these: TrN and TranslationServer.TranslatePlural:

TranslatePlural(
"You have {0} unspent skill point.",
"You have {0} unspent skill points.",
points
),

This will generate something like this in the .pot file:

#: Src/YourFile.cs:122
#, csharp-format
msgid "You have {0} unspent skill point."
msgid_plural "You have {0} unspent skill points."
msgstr[0] ""
msgstr[1] ""

Wrap your Translate call in string.Format (since you need Translate to take in a string literal for xgettext to find it):

string.Format(
Translate(
"Erratically shoots at a single enemy for {0} damage, but has a {1} chance to miss.",
"Abilities"
),
FormatNumber($"{damage:0}"),
FormatNumber($"{MissChance:P0}")
);

IMO, you should not have any BBCode enter your localization files because:

  • Your machine translations may try translating them.
  • They inflate the file and make it harder to tell what’s actually localized.

In the example below, the English string emitted is "5 damage (crit!)" (“crit!” is in red):

int damage = 5;
string critMessage = $"[color=red]{Tr("(crit!)")}[/color]";
string damageString = string.Format(Tr("{0} damage {1}"), damage, critMessage);

As shown above, the [color=red] and its corresponding closing tag, [/color], will not appear in the .pot file:

#: Src/YourFile.cs:119
msgid "(crit!)"
msgstr "(crit_in_another_language!)"
#: Src/YourFile.cs:120
#, csharp-format
msgid "{0} damage {1}"
msgstr "{0} damage_in_another_language {1}"
  • Godot has built-in pseudolocalization (reference)
  • Obsolete and fuzzy strings
    • #~ represents an obsolete string (one that doesn’t correspond to an ID in the .pot file) (reference).
    • A #, fuzzy comment means that an update was made to the source such that the translation probably needs to be looked at. For example, if you change “Fireball” to “Fireball 2” in C#, you’ll get the “fuzzy” tag in the resulting .pot and .po files. This means a translator should look at it.