Skip to content

Localization

@yagejs-addons/i18n translates the strings a player reads. One service holds the catalogs and the current locale; each engine text surface has a localized class that resolves its message through that service and updates when the locale changes. Switch language while a dialogue line is typing, with a choice menu open, or with the inventory panel open: the text swaps in place and no event fires.

The engine packages carry no localization code. Games that never translate install nothing.

Terminal window
npm install @yagejs-addons/i18n

@yagejs/core is the only required peer. Each optional peer sits behind its own subpath, so importing the root pulls neither pixi nor React:

EntryPeerWhat it adds
.@yagejs/coremsg, the Localization service, the i18next backend, the plugin
./renderer@yagejs/rendererlocalized TextComponent and SplitTextComponent
./ui@yagejs/uilocalized UISurface, a localized class per text widget, tooltip
./ui-react@yagejs/ui-react, reactuseLocalization, <Trans>, a localized select
./inventory@yagejs-addons/inventorylocalized inventory presenters

Create the service from preloaded catalogs and install the plugin:

import { Engine } from "@yagejs/core";
import { createLocalization, LocalizationPlugin } from "@yagejs-addons/i18n";
const localization = await createLocalization({
locale: "en",
fallbackLocale: "en",
catalogs: {
en: {
"hud.hp": "HP {hp}",
apples_one: "{count} apple",
apples_other: "{count} apples",
},
it: {
"hud.hp": "PV {hp}",
apples_one: "{count} mela",
apples_other: "{count} mele",
},
},
});
const engine = new Engine();
engine.use(new LocalizationPlugin(localization));

localization.setLocale("it") switches language. Every localized text on screen is updated before the call returns, in paused scenes too. An unknown locale throws before anything changes. Plural forms use i18next’s _one / _other suffixes, picked by a numeric count value.

A message is the catalog key, the text to show when the catalog lacks the key, and optional values for {name} tokens:

import { msg } from "@yagejs-addons/i18n";
const hp = msg("hud.hp", "HP {hp}", { hp: 55 });

msg returns plain frozen data; nothing is translated until a localized class resolves it. The same message works everywhere below.

A . in a key reaches into a nested catalog object ({ hud: { hp: … } } holds "hud.hp"). Any other character, : included, is part of the key, so a Yarn Spinner line id such as "line:abc" is used as is.

import { Transform } from "@yagejs/core";
import { msg } from "@yagejs-addons/i18n";
import { LocalizedTextComponent } from "@yagejs-addons/i18n/renderer";
const hud = scene.spawn("hud");
hud.add(new Transform());
const label = hud.add(
new LocalizedTextComponent({
message: msg("hud.hp", "HP {hp}", { hp: 55 }),
style: { fontSize: 16, fill: 0xffffff },
}),
);
// later, when hp changes
label.setMessage(msg("hud.hp", "HP {hp}", { hp: 40 }));

LocalizedSplitTextComponent does the same for split text. Both take the options of their base class with message in place of text.

LocalizedUISurface is a UISurface whose builders take a message as well as a string. Every localized element under it, however deeply nested, updates on a locale change:

import { Anchor } from "@yagejs/ui";
import { LocalizationKey, msg } from "@yagejs-addons/i18n";
import { LocalizedUISurface } from "@yagejs-addons/i18n/ui";
declare function start(): void; // your game's start action
declare function next(): string; // the next locale tag to show, e.g. "it"
const localization = entity.scene.use(LocalizationKey);
const menu = entity.add(new LocalizedUISurface({ anchor: Anchor.Center, padding: 12 }));
menu.text(msg("menu.title", "Observatory"), { fontSize: 24 });
menu.button(msg("menu.start", "Start"), { onClick: start });
menu.button(msg("menu.language", "Language"), { onClick: () => localization.setLocale(next()) });

menu.text(message, style, opts) accepts the same text options as UISurface, including width, wrapping, and truncation. To nest the label, pass the panel as the fourth argument: menu.text(message, style, opts, panel). localizedTooltip(anchor, scene, message) builds a tooltip bubble that follows the locale.

The surface builders cover text and buttons. For every other widget, build the localized class and add it with menu.addElement(element), or menu.addElement(element, panel) to nest it. The surface resolves it for the current locale as it goes in.

import {
LocalizedPixiRadioGroup,
UILocalizedCheckbox,
} from "@yagejs-addons/i18n/ui";
const radio = { checkedView: "radio-on", uncheckedView: "radio-off" };
const difficulty = new LocalizedPixiRadioGroup({
items: [
{ ...radio, text: msg("difficulty.easy", "Easy") },
{ ...radio, text: msg("difficulty.hard", "Hard") },
],
type: "vertical",
elementsMargin: 8,
selected: 0,
});
menu.addElement(difficulty);
menu.addElement(new UILocalizedCheckbox({ label: msg("settings.sound", "Sound"), checked: true }));
WidgetLocalized classMessage prop
UITextUILocalizedTextmessage
UISplitTextUILocalizedSplitTextmessage
UIButtonUILocalizedButtonmessage
UICheckboxUILocalizedCheckboxlabel
PixiSelectLocalizedPixiSelectitems
PixiFancyButtonLocalizedPixiFancyButtontext
PixiCheckboxLocalizedPixiCheckboxtext
PixiRadioGroupLocalizedPixiRadioGroupitems[].text
PixiInputLocalizedPixiInputplaceholder

Each class exposes its text as message and replaces it with setMessage(message). The dropdown and the radio group use messages and setMessages(messages). On the radio group, setMessages relabels the rows it already has, and setItems(items, selected?) replaces them, views and all. Each class keeps the state a player expects to survive a language change: the selected row of a dropdown or radio group, a checkbox’s checked state, and text already typed into an input.

Only a LocalizedUISurface updates the elements under it. A localized element under a plain UISurface or a React UIRoot keeps the text it was built with, and one added with a panel’s own addElement shows its fallback until the next locale change.

import { Button, Panel } from "@yagejs/ui-react";
import { msg } from "@yagejs-addons/i18n";
import { Trans, useLocalization, LocalizedPixiSelect } from "@yagejs-addons/i18n/ui-react";
declare function nextLanguage(): void;
declare function pick(index: number): void;
function Settings() {
const { t, locale } = useLocalization();
return (
<Panel direction="column" gap={8}>
<Trans message={msg("settings.title", "Settings")} style={{ fontSize: 20 }} />
<Button onClick={nextLanguage}>
{t(msg("settings.language", "Language: {locale}", { locale }))}
</Button>
<LocalizedPixiSelect
closedBG="select-closed"
openBG="select-open"
items={[msg("difficulty.easy", "Easy"), msg("difficulty.hard", "Hard")]}
onSelect={pick}
/>
</Panel>
);
}

A component that calls useLocalization() re-renders on each locale change. <Trans> accepts every <Text> prop.

The dialogue addon needs no import from this one. Its text fields accept the message shape msg returns, and DialogueController finds the installed service by itself:

import { defineScript, DialogueController } from "@yagejs-addons/dialogue";
import { createBoxDialogue } from "@yagejs-addons/dialogue/presenters";
import { msg } from "@yagejs-addons/i18n";
const SCRIPT = defineScript({
id: "mira",
start: "intro",
speakers: { mira: { name: msg("speaker.mira", "Mira") } },
nodes: {
intro: {
id: "intro",
steps: [
{ kind: "say", speaker: "mira", text: msg("mira.greet", "Welcome, {name}.", { name: "Ari" }) },
{
kind: "choice",
text: msg("mira.where", "Where to?"),
options: [{ text: msg("mira.forest", "The forest"), target: "forest" }],
},
],
},
forest: {
id: "forest",
steps: [
{ kind: "say", speaker: "mira", text: msg("mira.forest.path", "Follow the path north.") },
{ kind: "end" },
],
},
},
});
const npc = scene.spawn("npc");
const dialogue = npc.add(new DialogueController({ ...createBoxDialogue() }));
dialogue.play(SCRIPT);

A locale change mid-line keeps the typewriter where it is and swaps the text under it; an open choice menu keeps its highlighted row. Any other translation library plugs into dialogue, with or without this addon: pass an I18nAdapter as the i18n option of DialogueController (see the dialogue page).

Item and action definitions keep plain strings. The string is the fallback and the id picks the catalog key:

import { defineItems, Inventory, InventoryController } from "@yagejs-addons/inventory";
import { createInventoryPanel } from "@yagejs-addons/inventory/presenters";
import { localizeInventoryPanel } from "@yagejs-addons/i18n/inventory";
const ITEMS = defineItems({ potion: { name: "Potion", description: "Restores health." } });
const inventory = new Inventory({ catalog: ITEMS });
const bundle = localizeInventoryPanel(createInventoryPanel(), {
item: (id) => `item.${id}.name`,
description: (id) => `item.${id}.description`,
action: (id) => `action.${id}`,
title: "inventory.title",
});
entity.add(new InventoryController({ ...bundle, inventory, title: "Backpack" }));

The wrapped presenters resolve names, descriptions, action labels, and the title when they draw, and redraw on a locale change. An open action menu keeps its highlighted row. Quests need nothing: key a title by quest id and render it through any localized text class.

The plugin updates any component that implements relocalize:

import { Component } from "@yagejs/core";
import { TextComponent } from "@yagejs/renderer";
import { msg, type MessageResolver, type Relocalizable } from "@yagejs-addons/i18n";
class ScoreLabel extends Component implements Relocalizable {
score = 0;
private readonly label = this.sibling(TextComponent);
relocalize(resolve: MessageResolver): void {
this.label.setText(resolve(msg("hud.score", "Score {n}", { n: this.score })));
}
}

Any object with locale, resolve, setLocale, and subscribe is a valid service; pass it to LocalizationPlugin in place of the i18next one.

The Localization example in the examples app drives every surface above from the keyboard: L switches language mid-line, on the choice menu, and with the backpack menu open.

Coding agents: fetch https://yage.dev/llms.txt first and prefer the Markdown references it links over these HTML pages. This page's Markdown counterpart is /llms/addons/i18n.md.