Game Development with Godot & GDScript Module 11 – UI, menus, accessibility and localization
Localization and translations
Translate a Godot 4.7 game with keys and tr(), load CSV and gettext PO files, and handle plurals, placeholders and language changes at run time.
What you will learn
- Use translation keys, tr() and automatic Control translation for every text players see
- Load translations from a CSV file and from gettext PO files
- Switch the language at run time with correct plural forms and placeholders
- Test layouts against longer text with pseudolocalization
Before you start
What you’ll learn · Before you start
What you will learn
- Use translation keys, tr() and automatic Control translation for every text players see
- Load translations from a CSV file and from gettext PO files
- Switch the language at run time with correct plural forms and placeholders
- Test layouts against longer text with pseudolocalization
Before you start
On this page
“You found 0 coins” is right in English and wrong in French, which uses the singular for zero: “Tu as trouvé 0 pièce”. Localization is more than a table of words. Every text needs a key that translators can see, the right form for every count, a word order that each language can change, and a layout that survives longer text.
This page builds translations for a small menu in English, French and Hindi: keys looked up through TranslationServer, translations from a CSV file and from a gettext PO file, and messages with plurals and named placeholders. All of it ran on Godot 4.7.2 without a window.
Translation keys and tr() look text up in the current language
Many games use translation key: A code such as MENU_PLAY that stands for a piece of text in every language. A Control whose text is a key shows its translation, and tr() looks one up in code; a key without a translation is shown as it is. such as MENU_SETTINGS instead of plain English
text: the Internationalizing games page warns that one English phrase can need different translations, and a key has
one meaning. menu.csv holds
four keys in three languages; the session imports it, as the editor does when it first sees the file, lists the
.translation files the import made in the project’s translations, and looks the keys up in five
locale: A code for a language and, optionally, a region or script, such as fr, fr_CA or pt_BR. TranslationServer.set_locale() chooses the one translations are looked up for, and a regional locale can use the translation of its language.:
keys,en,fr,hi
MENU_PLAY,Play,Jouer,खेलें
MENU_SETTINGS,Settings,Paramètres,सेटिंग्स
MENU_QUIT,Quit,Quitter,बाहर निकलें
GREETING,Hello,Bonjour,नमस्ते extends SceneTree
## The translations of menu.csv, which the project lists in Project Settings > Localization, looked up
## in five locales; then a Button whose text is a key, and a Label that must never be translated.
func _initialize() -> void:
print("Loaded locales: %s" % ", ".join(TranslationServer.get_loaded_locales()))
for locale in ["en", "fr", "fr_CA", "hi", "de"]:
TranslationServer.set_locale(locale)
print(" %-5s GREETING -> %-8s MENU_SETTINGS -> %-11s NOPE -> %s" % [locale, TranslationServer.translate("GREETING"),
TranslationServer.translate("MENU_SETTINGS"), TranslationServer.translate("NOPE")])
await process_frame
var play := Button.new()
play.text = "MENU_PLAY"
var player_name := Label.new()
player_name.text = "GREETING"
player_name.auto_translate_mode = Node.AUTO_TRANSLATE_MODE_DISABLED
root.add_child(play)
root.add_child(player_name)
for locale in ["en", "fr"]:
TranslationServer.set_locale(locale)
print("%s: the Button's text is %s and it shows %s; the Label with auto-translation off shows %s" % [locale, play.text,
play.atr(play.text), player_name.atr(player_name.text)])
quit() from session import files, run, write
# menu.csv imported as the editor imports it, then listed in the project's translations and looked up.
files("project.godot", "keys/menu.csv=menu.csv", "keys/lookup.gd=lookup.gd")
run("""
godot --headless --quiet --import
ls *.translation
""")
files("project_translations.godot=project.godot")
run("""
godot --headless --no-header --script lookup.gd
""") Output
$ godot --headless --quiet --import $ ls *.translation menu.en.translation menu.fr.translation menu.hi.translation $ godot --headless --no-header --script lookup.gd Loaded locales: en, fr, hi en GREETING -> Hello MENU_SETTINGS -> Settings NOPE -> NOPE fr GREETING -> Bonjour MENU_SETTINGS -> Paramètres NOPE -> NOPE fr_CA GREETING -> Bonjour MENU_SETTINGS -> Paramètres NOPE -> NOPE hi GREETING -> नमस्ते MENU_SETTINGS -> सेटिंग्स NOPE -> NOPE de GREETING -> Hello MENU_SETTINGS -> Settings NOPE -> NOPE en: the Button's text is MENU_PLAY and it shows Play; the Label with auto-translation off shows GREETING fr: the Button's text is MENU_PLAY and it shows Jouer; the Label with auto-translation off shows GREETING
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 keys_session.py
Follow four of the lookups:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- A Button with a key. The Button's text is MENU_PLAY. Its auto_translate_mode is Inherit, and the root translates, so it looks the key up for the current locale and draws the answer.
- French. With the locale fr, the fr translation has MENU_PLAY: the Button shows Jouer, and GREETING gives Bonjour.
- French as in Canada. No translation was made for fr_CA, but the fr one matches its language, so fr_CA got Bonjour and Paramètres too.
- German. No translation matches de at all, so Godot used the fallback locale, en: Hello and Settings.
- A missing key. NOPE is in no translation, so translate("NOPE") returned NOPE unchanged. A key on screen in capitals is a missing translation, easy to spot in a test.
tr() and TranslationServer.translate() do the lookup in code. A key that no translation has comes back unchanged,
as NOPE did, and the Object reference says the same of tr(). Most Controls need
no code at all: the Button’s text stayed MENU_PLAY, and it showed Play, then Jouer. Text that must never be
translated, such as a player’s name, goes in a Label with auto_translate_mode set to Disabled, which kept GREETING
as typed.
Checkpoint
Check: Translation keys, tr() and automatic translation
A CSV file for small games, PO files for translators
Godot reads translations from two kinds of text file. A CSV file has a keys column and one column per locale, and the editor
imports it into one .translation file per language. A gettext PO file pairs each msgid, the source text, with its
msgstr and loads as it is. two_formats.gd uses both, each with plural forms and contexts:
keys,?plural,?context,en,fr
COINS_FOUND,COINS_FOUND_PLURAL,,You found %d coin,Tu as trouvé %d pièce
,,,You found %d coins,Tu as trouvé %d pièces
CLOSE,,door,Close,Fermer
CLOSE,,distance,Close,Proche # French translation of Lantern Road's shop.
msgid ""
msgstr ""
"Language: fr\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
msgid "You found %d key"
msgid_plural "You found %d keys"
msgstr[0] "Tu as trouvé %d clé"
msgstr[1] "Tu as trouvé %d clés"
msgctxt "door"
msgid "Open"
msgstr "Ouvrir"
msgctxt "shop"
msgid "Open"
msgstr "Ouvert" extends SceneTree
## Translations from two formats side by side: items.csv, imported into one .translation per language,
## and fr.po, loaded as it is. Both use plural forms and contexts.
func _initialize() -> void:
for path in ["res://items.en.translation", "res://items.fr.translation", "res://fr.po"]:
var translation: Translation = load(path)
TranslationServer.add_translation(translation)
print("%s: a %s for locale %s" % [path.get_file(), translation.get_class(), translation.locale])
for locale in ["en", "fr"]:
TranslationServer.set_locale(locale)
var counts: PackedStringArray = []
for n in [0, 1, 2]:
counts.append(TranslationServer.translate_plural("COINS_FOUND", "COINS_FOUND_PLURAL", n) % n)
print("%s, from the CSV: %s" % [locale, " | ".join(counts)])
print(" CLOSE for a door: %s; CLOSE for a distance: %s" % [TranslationServer.translate("CLOSE", "door"),
TranslationServer.translate("CLOSE", "distance")])
TranslationServer.set_locale("fr")
var keys: PackedStringArray = []
for n in [0, 1, 3]:
keys.append(TranslationServer.translate_plural("You found %d key", "You found %d keys", n) % n)
print("fr, from the PO file: %s" % " | ".join(keys))
print(" Open for a door: %s; Open for a shop: %s" % [TranslationServer.translate("Open", "door"), TranslationServer.translate("Open", "shop")])
quit() from session import files, run
# A CSV with plural and context columns, imported, and a gettext PO file, loaded as it is.
files("project.godot", "files/items.csv=items.csv", "files/fr.po=fr.po", "files/two_formats.gd=two_formats.gd")
run("""
godot --headless --quiet --import
ls *.translation
godot --headless --no-header --script two_formats.gd
""") Output
$ godot --headless --quiet --import
$ ls *.translation
items.en.translation
items.fr.translation
$ godot --headless --no-header --script two_formats.gd
items.en.translation: a Translation for locale en
items.fr.translation: a Translation for locale fr
fr.po: a Translation for locale fr
en, from the CSV: You found 0 coins | You found 1 coin | You found 2 coins
CLOSE for a door: Close; CLOSE for a distance: Close
fr, from the CSV: Tu as trouvé 0 pièce | Tu as trouvé 1 pièce | Tu as trouvé 2 pièces
CLOSE for a door: Fermer; CLOSE for a distance: Proche
fr, from the PO file: Tu as trouvé 0 clé | Tu as trouvé 1 clé | Tu as trouvé 3 clés
Open for a door: Ouvrir; Open for a shop: Ouvert
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 files_session.py
| What | CSV | gettext PO |
|---|---|---|
| One file holds | every language, one column each | one language |
| Edited with | a spreadsheet | translators’ PO editors |
| Plural forms and contexts | ?plural and ?context columns, since 4.6 |
msgid_plural and msgctxt |
| In the project | imported into .translation files |
loaded as it is |
The context told CLOSE for a door, Fermer, from CLOSE for a distance, Proche. The CSV page asks for UTF-8 without a byte order mark, and for double quotes around any message holding a comma, a line break or a quote. The gettext page adds that PO files work better with version control and give translators tools that find untranslated and outdated text.
CSV Editor & Viewer Open a translation CSV as a table and check that every key has a cell in each language column.Checkpoint
Check: CSV and gettext PO files
Plural forms and named placeholders keep each language’s grammar
tr_n(), or translate_plural(), takes a message, its plural and the count, and picks the
plural form: One of the versions of a sentence that a language uses for different counts, such as 1 coin and 2 coins. Japanese uses one, English and French two, Russian three; a plural rule chooses, and tr_n() picks the form for a number. for the current language: the run gave “0 coins” in English and “0 pièce”
in French. Word order is the other trap. placeholders.gd builds one quest message in three languages, once from
translated fragments joined in English order and once from a whole translated sentence with named placeholders:
extends SceneTree
## One quest message in three languages, built two ways: from translated fragments joined in English
## order, and from one translated sentence with named placeholders. The translations are made in code
## here to keep the example short; a game keeps them in its CSV or PO files.
const MESSAGES := {
"en": {"QUEST_DONE": "{player} found {item} in {place}", "FOUND": "found", "IN": "in"},
"fr": {"QUEST_DONE": "{player} a trouvé {item} dans {place}", "FOUND": "a trouvé", "IN": "dans"},
"hi": {"QUEST_DONE": "{player} को {place} में {item} मिला", "FOUND": "मिला", "IN": "में"},
}
const WORDS := {
"en": {"item": "the lantern", "place": "the cave"},
"fr": {"item": "la lanterne", "place": "la grotte"},
"hi": {"item": "लालटेन", "place": "गुफा"},
}
func _initialize() -> void:
for locale: String in MESSAGES:
var translation := Translation.new()
translation.locale = locale
for key: String in MESSAGES[locale]:
translation.add_message(key, MESSAGES[locale][key])
TranslationServer.add_translation(translation)
for locale: String in MESSAGES:
TranslationServer.set_locale(locale)
var item: String = WORDS[locale].item
var place: String = WORDS[locale].place
var joined := "Asha " + TranslationServer.translate("FOUND") + " " + item + " " + TranslationServer.translate("IN") + " " + place
var named := String(TranslationServer.translate("QUEST_DONE")).format({"player": "Asha", "item": item, "place": place})
print("%s joined: %s" % [locale, joined])
print(" named: %s" % named)
quit() from session import files, run
# One quest message in English, French and Hindi, joined from fragments and built with named placeholders.
files("project.godot", "plurals/placeholders.gd=placeholders.gd")
run("""
godot --headless --no-header --script placeholders.gd
""") Output
$ godot --headless --no-header --script placeholders.gd
en joined: Asha found the lantern in the cave
named: Asha found the lantern in the cave
fr joined: Asha a trouvé la lanterne dans la grotte
named: Asha a trouvé la lanterne dans la grotte
hi joined: Asha मिला लालटेन में गुफा
named: Asha को गुफा में लालटेन मिला
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 placeholders_session.py
Joined fragments worked in French by luck and broke in Hindi, which puts the verb last: “Asha मिला लालटेन में गुफा”
is not a sentence. With named placeholders the translator wrote the whole sentence,
{player} को {place} में {item} मिला, and format() filled it in. The Internationalizing games page gives the same advice: use named placeholders
wherever possible, so that translators choose the order.
Localization has three more parts: plural rules per language, which decide which form a count gets; text that code built before the player switched the language; and pseudolocalization, which tests layouts before any translation exists.
Key idea
Translate whole sentences with named placeholders and let tr_n() choose the form for each count; never join
translated fragments in your own language’s order.
Checkpoint
Check: Plural forms and named placeholders
Plural rules by language, and the Hindi forms in the wrong order Pro: opens with a Pro, Premium or Ultimate pass
A plural form is chosen by a rule, a small formula from the count to the index of the form, and each language has its own. This part prints Godot’s rules for six languages, reproduces Hindi forms in the wrong order with a CSV that looks right, fixes it in one row, and checks Russian’s three forms.
In this part:
- The rules Godot uses, and a Hindi table read the wrong way round
- Three forms, and the numbers that test them
4 runnable examples (CSV, GD, Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Changing the language while the game runs Pro: opens with a Pro, Premium or Ultimate pass
Players expect to switch the language in the options menu and see the change at once, everywhere. This part changes the locale three times under an open menu, shows the one kind of text that stays behind, and adds the automatic choice and right-to-left layouts.
In this part:
- What follows a language change, and what stays behind
- The automatic choice, and the list of languages
- Languages written right to left
1 diagram · 2 runnable examples (GD, Python) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Pseudolocalization, and a lab in two more languages Pro: opens with a Pro, Premium or Ultimate pass
Translations arrive late, and layouts break when they do. This part makes text longer and stranger before any translation exists, measures what it does to a menu, and ends with a lab whose checker reads every text in three languages.
In this part:
- Text made longer before any translation exists
- Lab: a bag screen in English, French and Hindi
5 runnable examples (GD, Python) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise
These questions stay with keys, the two file formats and the grammar of plurals and placeholders, and start on the concept you have the least practice with.
Practice that adapts to you
Practise this lesson
9 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: why do many Godot games use translation keys such as MENU_PLAY instead of English text as the source?
A key names a piece of text, while an English phrase can mean several things: the Internationalizing games page warns
that plain English source strings can be ambiguous, because one phrase may need different translations. A key such as
MENU_PLAY or LEVEL_5_NAME has one meaning, and a Control whose text is a key translates itself. Keys also show up
when a translation is missing: on Godot 4.7.2 a missing key came back unchanged, so a capitalised key on screen is easy
to spot in testing. gettext projects usually keep English as the source instead, and add a context where one phrase
needs two translations.
-
Mid to senior
Hindi players report "1 सिक्के" and "2 सिक्का": the item counts use the wrong form, while French and English are right. The translations come from a CSV with the singular in the first row and the plural in the second for every language. What happened, and how do you fix it and keep it fixed?
-
Fresher to mid
After players change the language in the options menu, most of the UI switches at once, but the quest tracker and the coin counter stay in the old language until the next level loads. Why, and how do you fix it everywhere?
-
Senior to staff
Plan the localization of a game into eight languages, among them Hindi and Arabic: the file formats, the work with translators, testing before translations exist, and the checks before each release.
Key takeaways
- A Control whose text is a translation key shows the key’s translation for the current locale;
tr()does the same in code, and a missing key comes back unchanged. - A CSV file holds every language and is imported into
.translationfiles, while a gettext PO file holds one language and loads as it is; both support plural forms and contexts. - Translate whole sentences with named placeholders, and let
tr_n()pick the plural form for each count.
Terms in this lesson
- locale
- A code for a language and, optionally, a region or script, such as fr, fr_CA or pt_BR. TranslationServer.set_locale() chooses the one translations are looked up for, and a regional locale can use the translation of its language.
- plural form
- One of the versions of a sentence that a language uses for different counts, such as 1 coin and 2 coins. Japanese uses one, English and French two, Russian three; a plural rule chooses, and tr_n() picks the form for a number.
- translation key
- A code such as MENU_PLAY that stands for a piece of text in every language. A Control whose text is a key shows its translation, and tr() looks one up in code; a key without a translation is shown as it is.
References
- Internationalizing games (Godot Engine)
- Localization using spreadsheets (Godot Engine)
- Localization using gettext (Godot Engine)
- Pseudolocalization (Godot Engine)
- TranslationServer (class reference) (Godot Engine)
- Object (class reference): tr() and tr_n() (Godot Engine)
- Node (class reference): auto_translate_mode and NOTIFICATION_TRANSLATION_CHANGED (Godot Engine)
- Godot 4.6 release notes: CSV translation context and plural columns (Godot Engine)
- Unicode CLDR: Language Plural Rules (Unicode Consortium)
- Godot 4.7.2 source: locales.h (the built-in plural rules) (Godot Engine)
- Godot 4.7.2 source: translation_loader_po.cpp (the Plural-Forms header) (Godot Engine)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress