Author translations as UTF-8 JSON; Shinra compiles them before Zig compilation and during asset cooking. Hikari never parses localization JSON or message patterns at runtime. The editor and games use the same binary catalog reader and formatter.
Authoring
Each domain is one directory under assets/localization/. The game domain provides the short gameplay helpers; additional domains are exposed through hi.texts.<domain>.
assets/localization/game/en.localization.json
assets/localization/game/cs.localization.jsonThe source catalog owns message IDs and argument contracts:
{
"locale": "en",
"source_locale": "en",
"messages": {
"menu.play": "Play",
"welcome": {
"message": "Welcome, {name}!",
"args": { "name": "string" },
"description": "Greeting on the profile screen"
},
"inventory.items": {
"message": "{count, plural, =0 {Empty} one {# item} other {# items}}",
"args": { "count": "u64" }
}
}
}A translation can use plain message strings, including for parameterized messages. Argument declarations are inherited from the source:
{
"locale": "cs",
"messages": {
"menu.play": "Hrát",
"welcome": "Vítej, {name}!",
"inventory.items": "{count, plural, =0 {Prázdné} one {# předmět} few {# předměty} other {# předmětů}}"
}
}source_locale defaults to en. The filename must match locale; all catalogs in a domain must identify the same source locale. Missing translations are filled at cook time using the source text and source-language plural rules. Unknown keys, duplicate JSON keys, undeclared/unused arguments, incompatible argument declarations, invalid message syntax, and missing other branches fail compilation. Keep technical IDs, asset paths, script names, and saved scene identity separate from displayed text.
Supported arguments: string, integer (i64), number (f64), and explicit u32, u64, i32, i64, f32, f64. Named call arguments are checked at Zig compilation. The formatter accepts integer widths without forcing callers to cast ordinary counters.
Localization window
Open View → Localization…, or search for Localization in the command palette. The catalog selector lists project domains and, in a source checkout, the editor catalogs beside the configured engine SDK.
The message rail searches keys and text across all languages, and the All / Missing / Edited filter narrows it to the work left: Missing follows the shown target languages, Edited lists messages with unsaved changes anywhere. Each row carries one badge per shown target language — filled when translated, hollow when missing, accent when edited — so a message's state is readable without opening it. Arrow keys browse the rail; Enter, or ⌘/Ctrl with an arrow from anywhere in the window, steps to the next or previous message and lands the caret in the first translation editor.
Selecting a message shows its key (with a copy button), the translator description and the argument contract as chips, then one card per language: the source first, then every shown target, in one vertical scroll. Each card names its state (translated, missing, edited), the length of the text against the source, and flags a translation that drops a {argument} the source formats. An empty translation shows the source text ghosted, because that is what runs. Language chips in the toolbar show each target's coverage and an asterisk for unsaved edits; click one to show or hide it. Visibility is remembered per domain under .engine/user/localization-<domain>.json.
Copy source starts a translation from the source pattern; Use fallback removes the explicit translation. An empty edited translation is intentional empty text, distinct from fallback. Text areas support multiline input, selection, clipboard, IME composition, and undo/redo, up to 64 KiB per message. Hiding a language or selecting another message retains edits, but resets that field's local undo history.
The + next to the language chips adds a sparse catalog for a locale tag such as fr-CA; the + below the message rail adds a plain source message key. Both reveal a field, commit on Enter and close on Escape. Existing notes, argument declarations and extension metadata are preserved when editing text. Edit argument contracts and translator descriptions in JSON; adding new domains also starts with JSON. For an editor language, also add its display-name key to the English editor catalog; see Editor localization.
Save all (⌘/Ctrl S) snapshots every language and runs the installed Shinra compiler in a background job. Invalid patterns or contracts leave authored files untouched and display the compiler diagnostic. Successful validation publishes each changed file atomically, after checking for external changes. If an I/O failure interrupts a multi-file save, completed files are acknowledged and the remaining edits stay dirty. Temporary validation artifacts live under .engine/cache/ and are removed afterwards; they are not game resources.
Reload reads external edits. Reloading, switching catalogs, closing the window, closing the project and quitting all stop at an unsaved-changes dialog offering Save, Discard or Cancel; the footer counts unsaved edits until they are written. Existing project wording is cooked through the normal live asset pipeline. New keys and generated locale APIs require a game-module rebuild; editor translations require rebuilding the editor executable.
Gameplay API
const hi = @import("hikari_game");
hi.ui().text(hi.text(.@"menu.play"), .{});
var buffer: [128]u8 = undefined;
const greeting = try hi.textFormat(.welcome, &buffer, .{ .name = "Ada" });
const inventory = try hi.textFormat(.@"inventory.items", &buffer, .{ .count = 3 });Formatting writes to caller memory. Reusing a buffer overwrites its previous result. Plain text lookup returns a borrowed slice. Neither operation allocates after a domain's first binding; message IDs index a dense table directly. Store IDs and arguments for persistent UI, and resolve text when drawing.
// Main-thread loading/UI boundary; no borrowed catalog/text views may survive it.
try hi.i18n().setLocale(hi.texts.game, "cs");
// Other domains share the same API.
const messages = hi.i18n().catalog(hi.texts.game);
const id = messages.resolve("welcome") orelse return error.MissingMessage;
const text = try messages.formatDynamic(id, &buffer, &.{.{ .text = "Ada" }});Dynamic lookup uses a binary search and formatting takes argument values in the generated contract's alphabetical order. Prefer typed IDs/named arguments in Zig. hi.i18n().revision() changes when a catalog or locale changes; retained consumers can invalidate their text caches from it. Do not retain numeric IDs across a game-module rebuild, because adding/removing keys changes the generated contract.
setLocale loads a compiled asset through AssetStore and can report AssetNotReady while Shinra cooks; retry at the next boundary. Other failures leave the active catalog intact. Locale selection is explicit (for example cs or pt-PT). A regional tag whose catalog does not exist steps down to its language subtag (pt-BR → pt), and only then to the source language; a locale with no catalog at all is an error, while missing individual translations always fall back to source. Games own locale persistence and language selection. Thomas demonstrates a queued language change, retry, and English/Czech menu.
The host owns its catalog bytes independently of reloadable game modules. A source-content fingerprint refreshes fallback text after module reload, and a schema fingerprint rejects incompatible cooked replacements. Catalog binding/locale changes run on the host thread; do not invoke the host service from parallel gameplay hooks. A caller may use an already acquired immutable catalog only while its owner guarantees that no replacement occurs.
Message language
Patterns support named substitutions, select, cardinal plural, and selectordinal. Plural branches accept zero, one, two, few, many, other, or exact values such as =0. # renders the nearest enclosing plural argument. Apostrophe escaping follows the message syntax: '{literal}' emits braces, '' emits an apostrophe, and apostrophes in ordinary words remain literal.
{value, number, integer}, fixed0 through fixed6, and percent are available. Numeric rendering currently uses invariant decimal notation; locale-specific grouping, decimal separators, dates, currencies, plural offsets, and arbitrary ICU formatters are outside this message language. Exact plural literals are limited to the safe f64 numeric range; integer argument comparisons and modulo plural rules preserve full-width counters.
Plural rules are compiled from Unicode CLDR 48, with its license included alongside the data. Kaji also stages the notice in licenses/UNICODE-LICENSE.txt for game/editor distributions (under Contents/Resources/licenses/ in a macOS app). The runtime evaluates bounded bytecode; it performs no pattern parsing, CLDR JSON decoding, or heap allocation. Nesting is limited to 16, branch counts to 64, and arguments to 32 per message. Built-in Inter and Geist atlases include the Czech letters used by the supplied translations. Font coverage for additional languages and text shaping remain the renderer's responsibility; adding a catalog does not add complex-script shaping or bidirectional layout.
Build, storage, and reload
Kaji's localization unit runs the Shinra catalog compiler before hikari. Generated bindings and compiled fallback resources live in <project>/.engine/localization/; these are build outputs, never authored files. kaji localization [--project=<dir>] runs that unit alone. Zig never regenerates the catalogs; build.zig compares each source catalog's keys with the generated Id enum and fails configure with that command when they drift, so a raw zig build test or check-windows after a catalog edit says "catalogs out of date" instead of "enum has no member". The standalone SDK game-module build invokes the same compiler, so the editor's Recompile action regenerates bindings too.
The asset cook writes localization/<domain>/<locale>.shinloc under its cooked root. Kaji ships these with game resources, either loose in data/ or inside the selected resource bundle. Localization JSON is not shipped by the asset pipeline. Each game module embeds its source-language binary as a cold-start fallback; translated binaries load through the normal asset store. New wording does not change the schema, while key/argument changes require recompiling game code.
Shinra's watcher recompiles changed catalogs. A source-catalog edit also invalidates sibling translations because their fallback messages and schema depend on it. Runtime asset publication replaces the active locale at the paused frame boundary, retaining the previous catalog on validation failure. Files loaded through packs use the same API as loose files.
The editor sources live in src/hikari/assets/localization/editor/. Its generated catalogs are embedded in the editor executable, including English fallback. Adding/changing editor translations requires rebuilding the editor; editor strings never depend on an open game's assets. Editor Settings → Appearance → Language persists the selection separately from game locale and invalidates retained text layout in all editor windows. The Czech editor catalog is an illustrative partial translation; missing entries use English. The Language choice lists every catalog in the editor domain; a new xx.localization.json needs one language_xx key in the English catalog (its own name, translated like any other string) and nothing else.
The binary HLC1 catalog is a cooked layout (sdk/src/localization/catalog.zig) in the shared container: explicit little-endian records and byte offsets, no serialized pointers. Loading validates all offsets, UTF-8, key ordering, and program bounds before publication. Source and translation share the same dense key order and schema fingerprint.
Verification
# Build products without launching them.
bin/kaji/kaji editor --workspace=<repo> --project=../hikari-games/example --type=dynamic --config=debug
bin/kaji/kaji game --workspace=<repo> --project=../hikari-games/thomas --type=monolithic --config=debug
# Compiler and binary-reader tests.
cargo test --manifest-path src/shinra/Cargo.toml localization --lib
# From src/hikari, after Kaji generated the bindings:
zig build test-localization -Dgame-src=../../../hikari-games/example/src