---
name: incanto-localization
description: Ship a game in more than one language: a scene-header `strings` table, `"@t:key"` text props, `engine.t()`, and a ready-made UiLanguageSelect that switches live and persists. English is the base — a key a locale omits falls back to it silently, on purpose. Use whenever a game needs a language other than English, or a language setting.
---

# Localization — English base, other languages on top

Ship a game in more than one language without a second build, a second scene, or
a page reload.

**English is the base and the default.** Every other locale is a partial overlay:
anything it does not translate falls back to English, silently.

## The rule that matters most

> When a translation is ambiguous, when the English term is the more precise one,
> or when the translation runs so much longer than the English that it breaks the
> layout — **ship the English.**

Leaving a key untranslated is a **decision**, not a gap to fill. `COMBO`,
`BOSS`, `LV.`, `HP`, `x1.5` are usually clearer in English to players in every
language, and a "Continue to the next chapter" that becomes three lines in a
button will break your menu. Omit the key and the English appears. Nothing warns,
nothing marks it, nothing logs.

The only mistake worth reporting is a key **nothing** declares — a typo, which
renders raw on screen. `bunx incanto-check` tells you at author time.

## Declaring strings

One `strings` block in the scene header, locale first:

```json
{
  "format": 1, "type": "scene", "name": "Menu",
  "strings": {
    "en": {
      "menu.start": "Start Game",
      "menu.options": "Options",
      "settings.language": "Language",
      "hud.wave": "Wave {n}",
      "hud.combo": "COMBO"
    },
    "ko": {
      "menu.start": "게임 시작",
      "menu.options": "설정",
      "settings.language": "언어",
      "hud.wave": "{n} 웨이브"
    }
  },
  "root": { "...": "..." }
}
```

`hud.combo` has no Korean entry on purpose — "COMBO" is the term players already
read. It shows as `COMBO` in both languages, and that is correct.

Tables **merge** across scenes: put your shared UI strings in the scene the game
boots from, and a level that adds three lines of its own keeps them.

## Using them

A text prop becomes translatable by naming a key with `@t:` — the same shape as
the `"$assetKey"` references you already write:

```json
{ "name": "Start", "type": "UiButton", "props": { "text": "@t:menu.start" } },
{ "name": "Wave",  "type": "UiText",   "props": { "format": "@t:hud.wave" } }
```

Works on every text-bearing widget: `UiText.text` and `.format`,
`UiButton.text`, `UiBar.label`, `UiSelect.label` **and `.options`**,
`UiBanner.show()`, `UiDialogue` lines/speakers/choices — and `Label3D` / 2D
`Label`, the text that lives in the world.

**A key with a `{}` slot needs `format`, not `text`.** A prop resolves the key
and paints the result verbatim: `"hud.wave": "Wave {n}"` in `text` puts the
characters `Wave {n}` on screen. `format` is the template `setText` fills:

```json
{ "name": "Wave", "type": "UiText", "props": { "format": "@t:hud.wave" } }
```
```ts
wave.setText(String(n));   // "Wave 3" — and it re-reads on a language switch
```
```json
"strings": { "en": { "hud.wave": "Wave {}" }, "ko": { "hud.wave": "{} 웨이브" } }
```

The slot moves with the language, which is the whole reason it is a slot.

From a behavior, `engine.t(key, params)`:

```ts
banner.show(this.engine.t('hud.wave', { n: this.wave }));
```

`{n}` slots are filled from `params` — this is the ONLY path that fills a NAMED
slot; a scene-JSON prop has no params to fill it from. A slot with no matching
param is left alone rather than blanked.

### Authoring the table

The scene header has a **strings** table: one row per key, one column per
language, `+ key` and `+ language`. Two things it shows that the JSON cannot:

- an **untranslated cell** shows the English it will silently fall back to,
  greyed — that fallback is the design, not an error, and this is where you see
  it happening;
- a key the **base locale is missing** is marked red, because that is the one
  case that really breaks: nothing to fall back to, so the raw key paints.

Deleting a key a prop still says asks first, and tells you how many props will
start painting the key. The raw-JSON `strings` field is still in **advanced**
for pasting a whole table in at once.

## Checking a translation without a browser

No headless check could see a translated string: a capture printed
`text="@t:menu.start"`, byte-identical in every language, so a green report
proved nothing. Two things fix that.

`runScript` takes a **`locale`** — the whole run happens in that language:

```ts
const ko = await runScript(sceneJson, { durationMs: 2000, locale: 'ko' });
```

And a capture records what a widget actually **paints**, beside the prop that
produced it:

```
/Root/Hud/Start UiText paints="시작" text="@t:menu.start"
```

`paints=` appears only when the words differ from the prop, so a game that
localizes nothing sees no extra noise. Put a `locale` run in your `verify.ts`
next to the English one and a broken translation fails the harness instead of
waiting for someone to open the page.

## The language picker

One node. Do not build your own.

```json
{ "name": "Language", "type": "UiLanguageSelect",
  "props": { "label": "@t:settings.language" } }
```

Its options are the locales your scene declares — a game shipping only English
shows only English — each labeled with its own endonym (`한국어`, not `Korean`),
because a player who cannot read the language on screen still has to find theirs.
Picking one switches the game **live** and persists the choice through
`engine.settings`, so the next visit opens in it.

### Text that lives in the world

`Label3D` and the 2D `Label` resolve `@t:` too, and re-bake their texture when
the language changes — a sign over a shop door, a nameplate, a damage number.
The prop keeps the marker; only the painted words change.

```json
{ "name": "Sign", "type": "Label3D",
  "props": { "text": "@t:sign.welcome", "height": 0.4 } }
```

## What NOT to localize

- **Option VALUES stay identifiers — but you can still translate what is shown.**
  `UiSelect.options` are what the game compares against, and `@t:` in an option
  changes only the words on screen: the value `changed` emits is exactly what
  you wrote. So `"options": "@t:diff.easy,@t:diff.hard"` displays 쉬움/어려움
  and still hands your handler `@t:diff.easy`.
- **Node names, group names, asset keys, signal names, action names.** These are
  identifiers.
- **Anything a behavior parses.** If code does `if (value === 'start')`, that
  string is an identifier wearing a label's clothes.

## Verifying

```bash
bunx incanto-check src/scenes/menu.scene.json
```

Reports any `@t:` key that no locale declares. It says nothing about a key one
locale omits — that is the design, not a warning.

To check a language actually renders, set it and read the frame:

```ts
game.engine.locale.locale = 'ko';
```

Switching takes effect on the **next frame** — nothing to reload, nothing to
invalidate. Widgets re-read their text every frame, which is what makes this
work.

## Fonts

The engine's HUD uses the platform font stack. On a machine with no CJK face
installed, Korean renders as boxes — that is the operating system, not the
engine. If you ship Korean to an audience you do not control, add a webfont in
your own page CSS and set it on the HUD layer.
