Internationalisation (i18n)
Localise your game into Bemba, Nyanja, Tonga, and Lozi with the built-in UbuntuI18n layer. Translations are generated on-device by the offline AI stack, cached locally, and always fall back to English.
How it works
UbuntuPlay's i18n model is English-first with pack-first translation:
- Every user-facing string is registered in an English dictionary (
defaultStrings) — the source of truth. - When a learner switches language,
UbuntuI18nloads the language's locale pack first (GET /api/locales/<lang>, static/locales/<lang>.jsonfallback) — compile-time translations plus teacher overrides that render instantly, offline, with zero AI. Both the classroom server and the Hub serve these routes, so games translate identically on school servers, marketplace demos, and parent-portal launches. - Only strings the pack does not cover ride the runtime pipeline via
UbuntuAI.batchTranslate()— the Gogo AI Bridge (LiteRT-LM + Gemma, in the teacher's browser) on classroom servers with an NLLB-200 fallback where installed, or the browser-local Gogo engine on the Hub. - Real results are cached in
localStorage— junk-screened first, so echoes, repetition artifacts and error strings never persist — and each string is translated once per device. - If anything fails — no pack coverage, no AI bridge, unsupported device — the UI stays in English and a floating notice says so honestly. Localisation is never allowed to break gameplay.
Supported languages
| Code | Language | Display name to pass |
|---|---|---|
en | English (source of truth) | 'English' |
bem | Bemba | 'Bemba' |
nya | Nyanja | 'Nyanja' |
ton | Tonga | 'Tonga' |
loz | Lozi | 'Lozi' |
setLanguage(lang, langName) takes both the code and a human-readable name. The name is what the AI translator is prompted with (e.g. "Translate into Bemba"), so pass the English display name shown in the table.Quick start
Include the AI helper and the i18n layer (both are served by the classroom server and the Hub):
<script src="/js/ai-helper.js"></script> <script src="/js/i18n.js"></script>
Mark up your UI with data-i18n keys (the element's English text doubles as the design-time placeholder):
<h1 data-i18n="mygame.title">Star Counter</h1> <button data-i18n="mygame.start">Start</button> <span data-i18n="common.score">Score</span>
Register your strings, then switch language and refresh:
// Register your game's English strings (once, at startup)
Object.assign(UbuntuI18n.defaultStrings, {
'mygame.title': 'Star Counter',
'mygame.start': 'Start',
'mygame.win': 'You counted {count} stars!'
});
// Switch language (async — translates any missing strings via the AI bridge)
const ok = await UbuntuI18n.setLanguage('bem', 'Bemba');
if (ok) UbuntuI18n.refreshUI(); // re-translates every [data-i18n] element
API reference
The layer is exposed as the global window.UbuntuI18n:
| Member | Description |
|---|---|
UbuntuI18n.t(key, params?) | Returns the translated string for the current language. Falls back to the English defaultStrings entry, then to the raw key. Interpolates {name} placeholders from params. |
UbuntuI18n.setLanguage(lang, langName) | Async. Sets the active language for this page load (never persisted — every page deliberately boots in English), dispatches up_lang_change, loads the locale pack, then batch-translates only pack-missing strings via window.UbuntuAI.batchTranslate(). Resolves true when the language is genuinely usable (at least half of all strings render translated), false otherwise (UI should stay in English). |
UbuntuI18n.getLanguage() | Returns the current language code (e.g. 'bem'). |
UbuntuI18n.refreshUI() | Sets textContent = t(key) on every element with a data-i18n attribute. |
UbuntuI18n.defaultStrings | The live English dictionary. Extend it with Object.assign() to register your game's keys. |
textContent, so any child HTML inside a data-i18n element is wiped on refresh. Put the attribute on a leaf element (a <span> around the text, not the button containing icons). It also does not translate attributes like placeholder or title — set those in JS with t().String interpolation
Use {name} placeholders in your defaults and pass values at lookup time. Placeholders are substituted after translation, so dynamic values are never sent to the translator:
// 'nzelu.puzzle_count': 'Puzzle {current} of {total}'
UbuntuI18n.t('nzelu.puzzle_count', { current: 3, total: 10 });
// → "Puzzle 3 of 10" (or the Bemba equivalent with numbers injected)
Naming your keys
Keys are flat, dot-namespaced strings: <namespace>.<section>.<name>. Each official game owns a namespace (assegai.*, chenjela.*, madzi.*, mankwala.*, nzelu.*, rangi.*), and shared UI strings live under common.*:
| Namespace | Use for | Examples |
|---|---|---|
common.* | Platform-wide strings — reuse these before adding your own | common.score, common.level, common.try_again, common.translating |
<your-game-id>.* | Everything specific to your game | mygame.title, mygame.hud.timer, mygame.result.win |
Keep keys stable once shipped — cached translations are stored against the English text, but your markup and code reference the key.
Reacting to language changes
setLanguage() dispatches a up_lang_change CustomEvent on window. Use it to re-render strings you build dynamically in JavaScript (canvas labels, toasts, generated lists):
window.addEventListener('up_lang_change', (e) => {
console.log('Language is now', e.detail.lang);
redrawHud(); // re-run anything that calls UbuntuI18n.t(...)
});
A complete language switcher
This is the exact pattern used by the official games (from Madzi):
<select id="lang-select"> <option value="en">English</option> <option value="bem">Bemba</option> <option value="nya">Nyanja</option> <option value="ton">Tonga</option> <option value="loz">Lozi</option> </select>
document.getElementById('lang-select').addEventListener('change', async (e) => {
const lang = e.target.value;
const langName = e.target.options[e.target.selectedIndex].text;
if (!window.UbuntuI18n) return; // degrade gracefully
toast(UbuntuI18n.t('common.translating')); // "Translating..."
const ok = await UbuntuI18n.setLanguage(lang, langName);
if (ok) {
UbuntuI18n.refreshUI();
toast(UbuntuI18n.t('common.translation_complete'));
} else {
toast(UbuntuI18n.t('common.translation_failed')); // UI stays in English
}
});
Where translations come from
setLanguage() delegates to UbuntuAI.batchTranslate(texts, langName) from /js/ai-helper.js, which posts to the batch endpoint:
| Context | Endpoint |
|---|---|
| Classroom server (games, student/teacher pages) | POST /api/ai/translate-batch |
Hub portals (/hub/, /org/, /dev/) | POST /hub/api/ai/translate-batch |
Request body: { "texts": ["Start", "Score"], "targetLang": "Bemba" } → response: { "ok": true, "translations": ["…", "…"] }. On the classroom server the endpoint routes through the Gogo AI Bridge and falls back to the NLLB-200 translation service when available; results are additionally cached server-side in data/translation_cache.json. If the batch call fails, the helper retries strings individually before giving up.
Translation Exchange (import / export)
Strings registered per this guide are automatically part of the platform's Translation Exchange standard (uplay-translations v1) — no extra work per game. Teachers and tools can download everything that still needs translation as CSV or JSON, hand it to a human translator, and upload the finished pairs back: every row is auto-mapped to its game and exact string instance by its catalog key (<gameId>.<instance>).
| Action | Classroom endpoint (teacher auth) |
|---|---|
| Download strings (CSV or JSON) | GET /api/teacher/translations/:lang/export?format=csv|json&scope=untranslated|machine|fixes|all&game=<id> |
| Upload translated pairs | POST /api/teacher/translations/:lang/import (standard JSON, { "csv": "…" }, or a legacy fixes file; ?dryRun=1 validates without saving) |
Uploaded pairs are saved as teacher overrides — they beat machine text immediately for every learner and join the Hub community corpus on the next sync. Rows whose English source has drifted, whose {placeholder} set changed, or whose key no longer exists are skipped per-row with reasons. The full file format spec is served at /locales/EXCHANGE-FORMAT.md on every classroom server.
Storage & caching
| Store | Key / location | Contents |
|---|---|---|
| Language choice | not stored | Language is a per-page-load choice — every page boots in English and the picker starts on English. Legacy up_lang values from older versions are cleared at boot. |
| Locale packs | GET /api/locales/<lang> · /locales/<lang>.json | Compile-time translations + teacher overrides (+ published community fixes on the Hub), fetched fresh per page load and validated lazily against the live defaultStrings. |
| Translation cache (client) | localStorage['up_translation_cache'] | { lang: { key: translatedText } } — written as batches complete. If the quota is exceeded the write is skipped and English is used. |
| Translation cache (server) | classroom/data/translation_cache.json | Server-side batch results, shared by all devices in the classroom. |
Best practices
- Author in English, always.
defaultStringsis the fallback of last resort — every key must have an English default. - Reuse
common.*keys for Score/Level/Retry-style strings; they are already translated and cached on most devices. - Never hardcode UI strings in JS. Route every user-visible string through
t()so language switches apply everywhere. - Keep strings short and self-contained. Small on-device models translate short UI strings far more reliably than long paragraphs. Do not split sentences across keys.
- Use interpolation for anything dynamic — numbers, names, and emoji survive translation only when they travel as
{placeholders}. - Test the failure path. Load your game with the AI bridge offline and confirm it is fully playable in English (
setLanguageresolvingfalsemust not break anything). - Check
window.UbuntuI18nexists before calling it — your game should also run standalone outside the platform.