Skip to content

Localization

Text in the player's language: one string table per locale, looked up by key, with placeholders and plural rules, switched while the game runs.

module: awake:core:localization Desktop Android iOS Web

Game data, UI and plugins store keys, such as quest.harbor.title, never the text itself. A Strings turns a key into text in the active locale. A scene's canvas text can show a key with no code, and a played project loads its tables itself; see Show a key in a scene. AwakeKt Studio has no translations view yet.

Write the string tables

A project keeps one JSON file per locale under strings/, named by its BCP 47 tag: strings/en.json, strings/ko.json, strings/pt-BR.json. Each maps keys to text. An object groups keys: menu.start below is the same key as a top-level "menu.start".

strings/en.json
{
  "menu": {
    "start": "Start",
    "settings": "Settings"
  },
  "quest": {
    "harbor": {
      "title": "The lost lantern",
      "objective": "{found, plural, =0 {Find the lanterns} one {# of {total} lanterns found} other {# of {total} lanterns found}}"
    }
  },
  "greeting": "Welcome back, {name}!",
  "party": "{count, plural, offset:1 =0 {You travel alone} =1 {You travel with {friend}} one {You travel with {friend} and # other} other {You travel with {friend} and # others}}"
}
strings/ko.json
{
  "menu": {
    "start": "시작",
    "settings": "설정"
  },
  "quest": {
    "harbor": {
      "title": "잃어버린 등불",
      "objective": "등불 {total}개 중 {found}개 찾음"
    }
  },
  "greeting": "다시 오신 것을 환영합니다, {name}님!"
}

The tables are project files like scenes and assets, so they ship with the project and the project-content plugin indexes them for the web by content hash.

Look up text

Load the tables through the project's AssetSource, in the locale the player chose or the system's:

Kotlin
val strings = Strings.load(
    StringTableSource.assets(assets), // reads strings/<locale>.json
    locale = LocaleId.of("ko"), // the player's setting, or systemLocale()
)

Then ask for text by key:

Kotlin
strings.text("menu.start") // "시작"
strings.text("greeting", "name" to "Mira") // "다시 오신 것을 환영합니다, Mira님!"
strings.text("party", "count" to 3, "friend" to "Ren") // not in ko.json: the English text
strings.text("quest.harbor.reward") // no table has it: "quest.harbor.reward", logged once

A key is looked up in the active locale's table, then in its parents' (pt-BR, then pt), then in the default locale's (en unless Strings.load is given another). A key that no table has shows as the key itself and is logged once. Nothing here throws for missing or broken text.

Placeholders, plurals and choices

Text follows ICU MessageFormat:

Pattern What it does
{name} Puts in the argument name as it is: a number is not grouped.
{n, number} Writes a number the locale's way: 1,234.5, 1.234,5, 1 234,5.
{n, number, integer} Rounds to a whole number: 1,235.
{n, number, percent} Times 100, with the locale's percent sign: 45%, 45 %, %45.
{n, number, compact} Short, for a HUD: 1.2K, 1,2 Mio., 1.2万. ICU's ::compact-short also works.
{n, plural, one {# item} other {# items}} Picks a branch by the CLDR plural category of n in the text's language. # is the number, written the locale's way.
{n, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} Picks a branch by the CLDR ordinal category: 1st, 2nd, 3rd, 4th, 11th, 21st.
=0 {…} Inside plural: matches an exact value before the categories.
offset:1 Inside plural: subtracts from the number before the category and #.
{g, select, female {…} male {…} other {…}} Picks a branch by the argument's text.
'' One apostrophe.
'{…}' Literal braces: a ' before {, } or, in a plural, # quotes up to the next '.
Kotlin
strings.text("quest.harbor.objective", "found" to 0, "total" to 5) // "Find the lanterns"
strings.text("quest.harbor.objective", "found" to 1, "total" to 5) // "1 of 5 lanterns found"
strings.text("party", "count" to 1, "friend" to "Ren") // "You travel with Ren"
strings.text("party", "count" to 2, "friend" to "Ren") // "You travel with Ren and 1 other"

plural and select need an other branch. An argument's value is shown as it is and never parsed, so a player name holding { is safe.

Languages count differently, and the categories a translation uses are its own language's:

Language Categories
English, German, Dutch, Swedish, Finnish, Estonian one for 1, other
Spanish, Italian one, many for millions, other
French, Portuguese (Brazil) one for 0 and 1, many for millions, other
Filipino one, except counts ending in 4, 6 or 9, which are other
Russian, Ukrainian one (1, 21), few (2–4, 22–24), many (5–20, 25), other for fractions
Polish one (1), few (2–4, 22–24), many, other for fractions
Arabic zero, one, two, few (3–10), many (11–99), other
Japanese, Korean, Chinese, Thai, Vietnamese, Indonesian, Malay other only

A language not listed counts everything as other; pass Strings.load a pluralRules function for it.

Ordinals have rules of their own. English uses one (1st, 21st), two (2nd, 22nd), few (3rd, 23rd) and other (4th, 11th–13th). Swedish uses one for 1st and 2nd, Italian many for 8, 11, 80 and 800, French, Filipino, Malay and Vietnamese one for 1 only, and Ukrainian few for 3rd. The other languages above use other for every number. Strings.load takes an ordinalRules function for a language not covered.

Numbers

{n, number}, its styles and a plural's # write numbers the way the text's language does:

Locale {n, number} of 12345.6 percent of 0.45 compact of 1234567
English 12,345.6 45% 1.2M
German 12.345,6 45 % 1,2 Mio.
French 12 345,6 45 % 1,2 M
Spanish 12.345,6, but 1234 45 % 1,2 M
Japanese 12,345.6 45% 123万
Turkish 12.345,6 %45 1,2 Mn

Spanish, Italian, Polish, Estonian and European Portuguese group only from five digits, so 1234 has no separator. Numbers round half to even, keep up to three fraction digits, and use Latin digits. compact keeps two significant digits below ten of a unit (1.2K) and whole numbers above (12K). A plain {n} stays as it is, so a year or an ID is never written 2,026.

The data is Unicode CLDR's, generated by ./gradlew :awake:core:localization:generateCldrNumberData for every language the plural rules cover. Another language uses CLDR's root format, which writes 12,345.6. Code that writes a number outside a message uses NumberFormat.forLocale(strings.locale.value).format(n, style). French and Russian group with a no-break space, which the built-in font draws as a space.

Switch the locale

Kotlin
strings.setLocale(LocaleId.of("ko")) // loads ko.json, then switches
strings.locale.value // ko
strings.text("menu.settings") // "설정"

setLocale loads the new tables first and then switches, so text is never half in one language. strings.locale is a StateFlow. AwakeKt Compose composes every frame, so UI that reads strings.text(…) while composing shows the new language in the next frame, with no restart.

systemLocale() reads the device's language: the JVM default on desktop and Android, the first preferred language on iOS, and navigator.language in the browser.

Text a plugin ships

A plugin or library that shows text ships its own defaults, so it works before a game translates a word, and the game still has the last word. It adds its tables as a layer:

Kotlin
// A plugin's own text: its English, and any translations it ships.
val hudDefaults = StringTableSource.of(
    mapOf(
        LocaleId.English to mapOf("hud.hp" to "HP", "menu.start" to "Begin"),
        LocaleId.of("ko") to mapOf("hud.hp" to "체력"),
    ),
)
strings.addSource(hudDefaults, StringLayer.Plugin)
strings.text("hud.hp") // "HP": only the plugin has it
strings.text("menu.start") // "Start": the game's strings/en.json overrides the plugin

There are three layers, StringLayer.Game, Plugin and BuiltIn. The game's own tables, the ones given to Strings.load, win over a plugin's, and a plugin's win over the text Core ships. Within a layer, a source added later wins. Each locale's table is the layers merged key by key, so the game overrides one plugin key in strings/en.json and keeps the rest.

The locale comes before the layer. A Korean player sees the plugin's Korean text before the game's English, because text in their language beats text in the wrong one; the game's English wins over the plugin's English. A game that renames a plugin's term therefore translates it into every locale it ships. removeSource takes a layer away again.

Show text in Compose UI

compose:localization gives Compose UI the active table without making core:localization depend on Compose:

Kotlin
context(_: Composer)
fun Hud() {
    ProvideStrings(strings, labels) {
        Text(stringResource("hud.items", "n" to bag.count))
    }
}

LocalStrings holds the Strings, and stringResource(key, args) reads it. AwakeKt Compose composes every frame, so the text shows a new locale on the frame after setLocale returns. A missing key, or no LocalStrings at all, shows the key and is logged once.

Name keys and buttons

Button prompts and hotkey labels name keys, mouse buttons and controller buttons in the player's language and in their platform's style:

Kotlin
val labels = InputLabels.load(strings, style = ShortcutStyle.Windows)
val save = KeyChord(Key.S, setOf(KeyModifier.Ctrl, KeyModifier.Shift))
labels.key(Key.Space) // "Space"
labels.chord(save) // "Ctrl+Shift+S"
labels.pointer(PointerButton.Secondary) // "Right click"
labels.gamepad(GamepadButton.South) // "A"
strings.setLocale(LocaleId.of("de"))
labels.chord(save) // "Strg+Umschalt+S"

InputLabels.load adds Core's built-in names, English, German, French and Spanish, at StringLayer.BuiltIn. ShortcutStyle.system() picks the style: Windows writes Win+Ctrl+Alt+Shift, Linux names Meta Super, and Apple platforms write Control+Option+Shift+Cmd, with Option for Alt. ShortcutStyle.primaryModifier is Cmd on Apple platforms and Ctrl elsewhere, for an editor-style KeyChord. ControllerLayout picks what controller buttons are printed with: Letters (A at the bottom), SwappedLetters (B at the bottom) or Shapes (Cross at the bottom).

The labels name what input actions describe. chord(chord) names a KeyChord or a GamepadChord, binding(description) any BindingDescription, and bindings(action) every way to trigger a ButtonAction, keys then pointer buttons then chords: E / Right click / Shift+E. Modifier keys are written in the platform's order, controller modifiers in GamepadButton order: LB+A.

A game overrides any name, or adds a language, with the same keys in its tables:

Key Names
input.key.<Key> A key, such as input.key.Space. Letters, digits and F-keys default to themselves.
input.pointer.<PointerButton> A mouse button, such as input.pointer.Primary.
input.gamepad.<GamepadButton> A controller button by position, such as input.gamepad.South.
<key>.windows, .linux, .apple A key's or mouse button's name on one platform, such as input.key.Meta.apple.
<key>.letters, .swapped, .shapes A controller button's name in one layout.
input.chord.separator Joins a chord: +. Set input.chord.separator.apple to "" and the modifiers to ⌃⌥⇧⌘ for Apple's symbols, with a font that has them.
input.bindings.separator Joins an action's bindings: /.

A platform or layout form wins over the plain key in the same locale, and the player's locale wins over the default's.

Show a key in a scene

A canvas_element of kind Text or Button shows a key's text with textKey in place of text. textArgs gives its placeholders values, and a bind's textArgs reads one from a component field the game sets, such as a count:

scenes/main.scene.json
{ "component": "canvas_element", "textKey": "hud.items", "textArgs": { "name": "Harbor Town" },
  "bind": { "node": "Player", "textArgs": { "count": "inventory.count" } } }

loadProject reads strings/ through the project's files, the way it reads scenes and assets, so the tables are indexed by content hash on the web like any other file. It reads the device's locale's table and strings/en.json, the default; another locale's table is read only when the game switches to it. LoadedProject.strings is that Strings, and every scene the project plays shows its keyed text from it. A settings screen switches language through it:

Kotlin
project.strings?.setLocale(LocaleId.of("ko"))

Keyed text changes on the next frame; the scene keeps playing. A project with no strings/en.json has no strings, and its keyed elements show their keys. A host that draws the canvas itself passes SceneCanvas(world, strings = …). The fields are in Game UI.

Fonts for other scripts

The built-in UI font draws Latin text. A locale whose script it lacks, such as Korean or Russian, names fallback fonts in strings/fonts.json:

strings/fonts.json
{
  "ko": [
    "fonts/NotoSansKR-Regular.otf"
  ],
  "ru": [
    "fonts/NotoSans-Regular.ttf"
  ]
}
Kotlin
val fonts = LocaleFonts.load(assets) // strings/fonts.json
val uiFont: UiFont = strings.loadFont(assets, fonts, primary = UiFonts.default())

loadFont bakes each fallback font with only the chars the loaded tables use that the primary font lacks, and returns one font, UiFonts.fallback(primary, fallbacks), that draws each char from the first face that has it. Give it to the UI, for example as Compose's LocalFont. Chars are read as Unicode code points, so a font is found for a CJK Extension B ideograph or an emoji as for any other.

Bake fonts off the frame thread

Baking takes tens of milliseconds for a hundred glyphs and seconds for thousands. Call loadFont when the locale changes, keep the result, and do not call it per frame. Text from outside the tables, such as a player's name, can hold chars no table has: pass them as extraChars.

No shaping

Text draws one grapheme cluster at a time, so the caret, Backspace and a cut line never split an emoji or a letter from its marks, but nothing is shaped: Thai's combining marks and Arabic's joined letters draw as separate glyphs, emoji draw as the font's missing-glyph box, and right-to-left layout is not supported yet.

How it works

Tables load per locale, when that locale is used: a project with many languages reads and parses only the active locale's chain and the default's, and drops the others when it switches. A message is parsed the first time it is shown and kept; text with no { or ' is shown as it is.

Debugging

Every problem is logged once per locale through Log, with the tag Strings:

Warning Means
No text for "<key>" No table in the chain has the key. The key is shown.
No string table for <locale> The locale and its parents have no table; the default is shown.
Text "<key>" in <locale> is not a valid message The text breaks the syntax. The key is shown.
Text "<key>" in <locale>: argument "<name>" is missing A placeholder had no value. {name} is shown.

The engine showcase's Localization sample switches its debug panel between English and Filipino: ./gradlew :samples:engine-showcase:run -Pawake.showcase=localization.

See also

  • Game UI, for the text that shows these strings.
  • Modules, for the awake-core-localization coordinate.