Repository navigation
RFC: Internationalisation — locale files with comptime-checked keys #809
Description
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 siteUsage-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.
ttakescomptime 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.@compileLogis not one — it prints, then ends the build witherror: 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
locales/scan, key codegen, statict(), usage-aware coverage diagnostics,setLocale- Interpolation:
tf(), per-key args, frame arena, placeholder-parity validation - Pack-scoped locales
- Plurals (CLDR categories)
Phase 1 alone converts the FP menu and is independently useful.
Open questions
- Frame arena ownership — engine-owned or game-owned?
Strict by default?Resolved: default is a warning scoped to used keys;strict = truepromotes it to a comptime error. Remaining: shouldstrictbe settable per-locale, sopt-BRcan ship strictly whilefris still in progress?- Key indices are assigned by scan order; sort deterministically so reordering a file can't change a key's meaning.
- Should a game be able to override a pack's string without forking the pack?
- Binary-size ceiling before on-demand loading wins.
- 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
apotema commentedon Aug 7, 2026
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.docs(rfc): i18n and game constants (#811)