Skip to content

RFC: Internationalisation — locale files with comptime-checked keys #809

Closed

Description

@apotema

RFC: RFC-I18N.md (in this repo, alongside RFC-FONT-LOADER.md / RFC-ASSET-STREAMING.md)

Problem

Games hardcode every user-visible string as a Zig literal — e.g. ui.button("New Game", …) in flying-platform-labelle/scripts/menu/menu_ui.zig. There is no way to ship a second language short of editing source. Nothing in core, engine, gui, or cli mentions locales today.

Rails' shape is the reference: one file per locale, nested keys, t("menu.new_game"), interpolation. What is not worth copying is the failure mode — Rails finds a typo'd key at lookup time and renders translation missing: en.menu.new_game to a user, because nothing validates keys until then.

The assembler already generates the game's wiring at build time. So that entire class of bug can be a compile error instead. That is the reason to build this into labelle rather than let each game roll a string table.

Proposal

JSONC, not YAML. labelle already parses JSONC for scenes and prefabs. YAML would add a second config dialect and a parser dependency for one feature. The transferable idea is one-file-per-locale with nested keys, not the syntax.

Convention-scanned locales/, flat, filename = BCP-47 tag (en.jsonc, pt-BR.jsonc). No registry to keep in sync — same rule scripts/ uses. Packs ship packs/<name>/locales/ with <pack>__ namespacing, as they already do for components and prefabs.

Generated nested key constants, so the dotted call site survives without flattening collisions (a.b_c vs a_b.c):

t(K.menu.new_game)   // K.menu.new_gme → compile error at the call site

Usage-aware build-time validation by the assembler:

Condition Result
Key used in code, in reference, missing from locale L Warning, naming the key and the locales missing it
Key unused, missing from locale L Silent (only under --verbose)
Key in L, absent from reference Build error — catches half-finished renames
Placeholder set differs across locales Build error, showing both sets
locales/ exists, no i18n.default declared Build error
i18n.default / .reference names a locale with no file Build error, with "did you mean"

i18n.strict = true promotes row 1 to a hard error — what a release build turns on. Default is warn: a key only the reference locale has yet is the normal state mid-feature.

Why usage-awareness works here and not in Rails

A key nothing renders is dead weight; a key some screen draws and only en defines is a user-visible hole. Rails cannot tell them apart — its keys are runtime strings and may be computed (t("menu.#{action}_label")), so static usage analysis is unsound and it checks nothing until lookup. labelle's key space is closed and syntactically visible: a Key can only be named as a K.<path> constant and cannot be built from a runtime string. "Which keys does this game use" becomes decidable at build time.

Two enforcement points, with different powers:

  • Call site, comptime — errors only. t takes comptime key: Key, so it instantiates only for keys actually referenced; a coverage check inside it is usage-driven by construction, no scan needed, and the diagnostic lands on the exact line that would have rendered the string. But it can only fail: Zig has no comptime warning. @compileLog is not one — it prints, then ends the build with error: found compile log statement (verified on 0.16.0).
  • Assembler, build time — warnings. The pass that already scans sources for script discovery also collects K.<path> references into a used-set and diffs it per locale.

So comptime enforces, the assembler advises; strict flips which one applies. Soundness rests on the closed key space — the one hole being @enumFromInt forging a Key no K. path names, which is pathological and out of contract.

No runtime fallback logic either way: codegen backfills a missing slot with the reference locale's string, so the table stays rectangular and the no-runtime-failure property holds. The warning tells you a slot was backfilled.

Interpolation with checked arguments — codegen knows each key's placeholders, so it emits a per-key args type:

tf(K.hud.stock, .{ .count = 5, .max = 10 })   // ok
tf(K.hud.stock, .{ .count = 5 })              // compile error: missing field 'max'

Split t() (no placeholders → [:0]const u8 straight from the static table, no allocation, hands directly to cimgui) from tf() (formats into a per-frame arena), so the zero-cost path stays honest rather than hiding an allocation behind a uniform API.

All locales baked into the binary; setLocale swaps an index. UI text is a few KB per locale. Combined with build-time completeness checking this means no runtime path can fail and no fallback code needs to exist.

No comptime interface slot — unlike render/audio/input, i18n has no backend variability. It is a generated module injected like gui_backend.

Configuration — project.labelle declares the default language

ZON, nesting like the existing .asset_compression / .backend_package entries:

.i18n = .{
    .default = "pt-BR",   // REQUIRED — the locale the game starts in
    .reference = "en",    // optional  — the locale keys are authored in (defaults to .default)
    .strict = false,      // optional  — promote coverage warnings to errors
},

.default is mandatory whenever locales/ exists. No implicit "en". A game's shipping language is a product decision, not something the build should guess from a filename — an inferred default is the kind of thing nobody notices until a launch happens in the wrong language. Games with no locales/ declare nothing and pay nothing.

Two settings, because they answer different questions. .default is what the player sees at first launch; .reference is which locale defines the canonical key set and backfills gaps. They coincide for most games, hence the fallback — they come apart when a studio authors keys in English but ships Portuguese first, where en is the complete canonical set and pt-BR is the startup language. One field would force that project to choose between an English launch and losing its authoring language as the coverage baseline.

Startup resolution, first match wins: LABELLE_LOCALE (dev/CI override — unknown tag warns and is ignored, never crashes a player's run) → persisted player choice (post-phase-1) → i18n.default.

Phasing

  1. locales/ scan, key codegen, static t(), usage-aware coverage diagnostics, setLocale
  2. Interpolation: tf(), per-key args, frame arena, placeholder-parity validation
  3. Pack-scoped locales
  4. Plurals (CLDR categories)

Phase 1 alone converts the FP menu and is independently useful.

Open questions

  1. Frame arena ownership — engine-owned or game-owned?
  2. Strict by default? Resolved: default is a warning scoped to used keys; strict = true promotes it to a comptime error. Remaining: should strict be settable per-locale, so pt-BR can ship strictly while fr is still in progress?
  3. Key indices are assigned by scan order; sort deterministically so reordering a file can't change a key's meaning.
  4. Should a game be able to override a pack's string without forking the pack?
  5. Binary-size ceiling before on-demand loading wins.
  6. A locale needing glyphs outside the baked atlas renders blanks. Should the assembler cross-check locale codepoints against FontBakeParams.ranges (RFC-FONT-LOADER §2) and fail the build? Both are build-time data, so the check is cheap.

Consumer

flying-platform-labelle's Options view is the proving ground — a language row next to Fullscreen/Vsync, with the menu's own strings as the first conversion.


Cleanup audit — 2026-09-08

The implementation and named consumer acceptance are merged. Assembler implements checked locale keys, interpolation, pack namespaces, usage diagnostics and plurals (#656, #660, #662, #663).

Flying Platform #786 migrated menus/save strings and added Options locale selectors in both menus using locales(), setLocale() and activeLocale(). Flying Platform #862 added complete pt-BR/pt-PT coverage and validated locale/layout screenshots.

Closing the feature RFC tracker. The original API sketches are design history; the shipped API includes a module-owned interpolation ring and the later pointer-returning helpers. Validation in this cleanup was merged-source/PR review; the linked PRs contain their own build and test evidence.

Activity

  1. added
    documentationImprovements or additions to documentation
    enhancementNew feature or request
    rfc/i18nInternationalisation: locale files & comptime-checked keys
    on Aug 7, 2026
  2. apotema commented on Aug 7, 2026

    @apotema
    ContributorAuthor

    Sibling RFC: #810 (game constants). Both are the same shape — scan a convention-named folder, generate nested namespaces of comptime-checked accessors, warn on entries nothing uses — and should share one assembler pass parameterised by root symbol (K. / C.) rather than growing two near-identical implementations. Whichever lands first should be built with the second in mind.

  3. added a commit that references this issue on Aug 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentationenhancementNew feature or requestrfc/i18nInternationalisation: locale files & comptime-checked keys

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions