Tutorial 98: Localization and Multiple Languages
What you’ll learn
- A JSON string table per language behind a
LocalizationManager. - Getting Unicode glyphs into a
SpriteFontand drawing them. - Date and number formatting, and the difficulty of right-to-left text.
- Detecting the system locale and hot-reloading strings in debug builds.
Before you start — Tutorial 09: Drawing Text with SpriteFont (Unicode is a font-descriptor problem) and Tutorial 45: ContentManager and Asset Pipeline (string tables load like any other asset).
String table approach (JSON per language)
Store all UI strings in JSON files, one per language. Load the correct file at startup based on the detected locale.
// assets/lang/en.json
{
"menu.play": "Play",
"menu.options": "Options",
"menu.quit": "Quit",
"hud.score": "Score: {0}",
"hud.lives": "Lives: {0}"
}
// assets/lang/cs.json
{
"menu.play": "Hrát",
"menu.options": "Možnosti",
"menu.quit": "Konec",
"hud.score": "Skóre: {0}",
"hud.lives": "Životy: {0}"
}
LocalizationManager class with JSON loading
#include "Microsoft/Xna/Framework/Game.hpp"
#include <string>
#include <unordered_map>
#include <fstream>
#include <sstream>
#include <stdexcept>
// Minimal JSON parser for flat string->string objects
// (In production, use nlohmann/json or rapidjson)
class LocalizationManager {
public:
void Load(const std::string& langCode) {
strings_.clear();
currentLang_ = langCode;
std::string path = "assets/lang/" + langCode + ".json";
std::ifstream f(path);
if (!f.is_open())
throw std::runtime_error("Language file not found: " + path);
std::string line;
while (std::getline(f, line)) {
// Very simple key-value extraction:
// "key": "value"
auto ks = line.find('"');
if (ks == std::string::npos) continue;
auto ke = line.find('"', ks + 1);
auto vs = line.find('"', ke + 2);
auto ve = line.rfind('"');
if (ks == std::string::npos || vs == std::string::npos || vs == ve) continue;
std::string key = line.substr(ks+1, ke-ks-1);
std::string val = line.substr(vs+1, ve-vs-1);
strings_[key] = val;
}
}
// Look up a string by key
const std::string& Get(const std::string& key) const {
auto it = strings_.find(key);
if (it == strings_.end()) return key; // fallback: return key itself
return it->second;
}
// Format: replace {0}, {1} with args
std::string Format(const std::string& key,
std::initializer_list<std::string> args) const {
std::string result = Get(key);
int i = 0;
for (const auto& arg : args) {
std::string placeholder = "{" + std::to_string(i++) + "}";
size_t pos;
while ((pos = result.find(placeholder)) != std::string::npos)
result.replace(pos, placeholder.size(), arg);
}
return result;
}
const std::string& CurrentLang() const { return currentLang_; }
private:
std::unordered_map<std::string, std::string> strings_;
std::string currentLang_;
};
// Global L("key") helper -- set g_loc before calling
static LocalizationManager* g_loc = nullptr;
inline const std::string& L(const std::string& key) {
return g_loc ? g_loc->Get(key) : key;
}
SpriteFont Unicode support
CNA's SpriteFont is a pre-rasterized atlas, not a runtime TrueType
renderer. Nothing is rasterized when the game starts, and the .cnj descriptor has no
notion of a TTF file, a point size, or character ranges to generate. A character is drawable if
and only if a glyph for its code point is already present in the atlas and listed in the
descriptor's glyphs array.
Coverage is therefore decided offline, when you build the atlas. For Latin scripts U+0020–U+00FF is usually sufficient. Czech and Slovak also need U+0100–U+017F, Cyrillic needs U+0400–U+04FF, and CJK needs a very large range (U+4E00–U+9FFF) and a font that actually contains those glyphs. Whichever set you choose, the resulting descriptor lists each glyph explicitly:
// assets/fonts/ui_font.cnj
{
"cnjVersion": 1,
"type": "SpriteFont",
"texture": "fonts/ui_font_atlas",
"lineSpacing": 22,
"spacing": 0.0,
"defaultCharacter": "?",
"glyphs": [
{ "char": 67, "source": [0, 0, 12, 18], "crop": [0, 0, 12, 18], "kerning": [0.0, 13.0, 0.0] },
{ "char": 269, "source": [12, 0, 12, 18], "crop": [0, 0, 12, 18], "kerning": [0.0, 13.0, 0.0] }
]
}
Above, char 269 is U+010D (č), a Czech character — it is available only
because the atlas was built with it included. A code point with no glyph entry falls back to
defaultCharacter, which is why an unlocalized-looking run of ? in
translated text almost always means the atlas is missing that script rather than that the
translation is wrong.
Loading and drawing a localized font
// Load a localization-aware SpriteFont through ContentManager.
// There is no SpriteFont constructor that takes a file path.
spriteFont_ = getContentProperty().Load<SpriteFont>("fonts/ui_font");
// Draw localized string:
spriteBatch_->DrawString(*spriteFont_,
L("menu.play"),
Vector2(100, 200),
Color::White);
Right-to-left text challenges
RTL languages (Arabic, Hebrew) require bidirectional text layout (Unicode BiDi algorithm).
CNA's SpriteFont renders text left-to-right by default. For RTL support, use a
BiDi library (libfribidi or ICU) to reorder characters before passing to
DrawString, then draw with SpriteEffects::FlipHorizontally if needed.
Date/number formatting
// Simple locale-aware number formatter
std::string FormatNumber(int n, const std::string& lang) {
// Czech uses space as thousands separator
// English uses comma
// For simplicity: just convert to string
return std::to_string(n);
// Production: use std::locale or ICU
}
Detecting system locale
#include <cstdlib>
#include <string>
std::string DetectSystemLocale() {
#if defined(_WIN32)
char buf[16];
GetLocaleInfoA(LOCALE_USER_DEFAULT, LOCALE_SISO639LANGNAME, buf, 16);
return std::string(buf);
#else
const char* lang = std::getenv("LANG");
if (!lang) return "en";
std::string l(lang);
// e.g. "cs_CZ.UTF-8" -> "cs"
auto under = l.find('_');
if (under != std::string::npos) l = l.substr(0, under);
auto dot = l.find('.');
if (dot != std::string::npos) l = l.substr(0, dot);
return l.empty() ? "en" : l;
#endif
}
Hot-reload strings in debug
#ifdef _DEBUG
// In Update(): watch for F5 to reload strings without restarting
void Update(GameTime& gt) override {
auto ks = Keyboard::GetState();
if (ks.IsKeyDown(Keys::F5) && !prevF5_) {
try {
localization_.Load(localization_.CurrentLang());
SDL_Log("Strings reloaded");
} catch (const std::exception& e) {
SDL_Log("Reload failed: %s", e.what());
}
}
prevF5_ = ks.IsKeyDown(Keys::F5);
}
#endif