Localization

Turian's localization system (ADR 0011) is shared by the engine, the shipped game, and Studio itself — one runtime, one authoring format, one CLI. This page covers the workflow: author → extract → translate → compile → ship.


Two ways to reach a string

Entry point For Key Authored by
tr("Open Scene…") UI chrome (menus, buttons, HUD labels) the source string itself programmers, in code
Locale.key("dlg.act1.intro") game content (dialogue, item names, quests) an explicit stable id designers, in a .strings asset

Both compile into the same table and are served by the same Locale service — the only difference is where the id comes from.

// Simple text
frame.tr("Open Scene…")

// Context-disambiguated (two identical English strings, different meaning)
frame.trc("door", "Open")

// Plural (CLDR cardinal rule picks the right form for the active language)
frame.trn("# file", "# files", count, &.{})

// Interpolated (ICU-subset named placeholders, not std.fmt specifiers)
frame.trArgs("Save changes to \"{title}\" before closing?", &.{
    .{ .name = "title", .value = .{ .text = doc_title } },
})

// Designer-authored game content, looked up by id
frame.trKey("dlg.act1.intro", &.{})

tr/trc/trn take their message as a comptime parameter — a non-literal argument is a compile error, not a review comment. A missing translation degrades to the English source, never to a raw key. key/trKey degrade to ⟦the.key⟧ in debug builds and the bare key in release, so a missing designer-content translation is never silently invisible.

Message syntax is a documented subset of ICU MessageFormat: {name}, {count, plural, one {# file} other {# files}} (with an optional =0 {...} exact-value branch), and {gender, select, male {He} other {They}}.

Registering the service

var locale = engine.Locale.init("en"); // default_locale
locale.loadTable(embedded_pt_br_strtab) catch {};
frame.services.register(engine.Locale, &locale);

Switch languages at runtime — no scene reload needed, since UI is re-rendered every frame:

locale.setLocale("pt-BR");

Loaded tables are cached for the session and never freed on switch, so any string slice already handed out stays valid (it just shows the old locale until re-fetched).

Authoring translations

A .strings file is the source of truth — JSON, one file per locale:

{
  "version": 1,
  "locale": "pt-BR",
  "units": [
    { "id": "Open Scene…", "source": "Open Scene…", "target": "Abrir Cena…", "state": "translated" }
  ]
}

Extract → translate → compile

# Walk a source tree for tr/trc/trn/trKey call sites, writing (or updating,
# preserving existing translations) a .strings file:
turian-cli i18n extract <src-dir> pt-BR my-project/i18n/pt-BR.strings.json

# Fill in "target" for each unit, then bake to the compiled runtime format:
turian-cli i18n compile my-project/i18n/pt-BR.strings.json my-project/i18n/pt-BR.strtab

extract is safe to re-run any time: matching ids keep their existing target; new call sites are added with state: "new". compile only emits units with a non-empty target — an untranslated string simply isn't in the table, and falls back to the English source at runtime, so partial translation coverage is never a hard error.

Translating Studio itself

Studio's own UI uses the identical mechanism, through studio/services/StudioLocale.zig (a thin wrapper exposing tr/trc/trn/trArgs backed by one Locale instance). Studio isn't a project, so its catalogs are @embedFiled rather than going through the asset pipeline: studio/i18n/<locale>.strings.json (source) and studio/i18n/<locale>.strtab (compiled, embedded).

To update Studio's pt-BR catalog after touching UI code:

turian-cli i18n extract studio pt-BR studio/i18n/pt-BR.strings.json
# translate any new units (state: "new") in the .strings.json
turian-cli i18n compile studio/i18n/pt-BR.strings.json studio/i18n/pt-BR.strtab

The language picker lives in Settings → UI → Language; switching takes effect immediately (Studio persists the choice to editor.ui.language).

Plurals

Plural category is resolved from CLDR cardinal rules for the active language — one/few/many/other (and zero/two for languages like Arabic) — not hand-written per-language conditionals. trn/{n, plural, ...} cover the common case; an explicit =0 {...}-style branch takes priority over the category when present.

Out of scope

RTL layout mirroring, BiDi shaping, and machine translation are not part of this system. A direction field is reserved on locale metadata for future RTL support.


← All docs Edit this page