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;
}
The line-based parser above is deliberately tiny and only understands one "key": "value" pair per line: it does not handle
escaped quotes, nested objects or values that span lines. Since CNA already depends on sharp-runtime, a robust alternative is its
System::Text::Json port. It lives in the Text.Json component, which is not part of CNA’s default set of
sharp-runtime components, so select it as shown in Tutorial 89:
#include "System/Text/Json/JsonDocument.hpp"
#include "System/Text/Json/JsonProperty.hpp"
void LocalizationManager::Load(const std::string& langCode) {
std::ifstream f("assets/lang/" + langCode + ".json");
if (!f.is_open())
throw std::runtime_error("Language file not found: " + langCode);
std::stringstream buffer;
buffer << f.rdbuf();
// Parse first, swap in afterwards: a broken file must not leave the game with half a table
auto doc = System::Text::Json::JsonDocument::Parse(buffer.str());
std::unordered_map<std::string, std::string> fresh;
for (const auto& prop : doc->getRootElementProperty().EnumerateObject())
fresh[prop.getNameProperty()] = prop.getValueProperty().GetString();
strings_ = std::move(fresh);
currentLang_ = langCode;
}
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 (with the build-time content pipeline, cna-content builds a
.spritefont description into a SpriteFont asset using FreeType — the character regions you list there decide what is in the
atlas — provided CNA was configured with its font pipeline, CNA_ENABLE_FONT_PIPELINE). 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, and Load<T> returns the font BY VALUE.
// SpriteFont has no default constructor, so hold it in a std::optional member:
// std::optional<SpriteFont> spriteFont_;
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
For real locale-aware formatting, start from the culture CNA gives you (see the next section) and from sharp-runtime’s
System::Globalization component, which is part of CNA’s default set of components; the stub below only marks
where such a formatter goes.
// 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
You do not have to parse environment variables yourself. When a Game is constructed it initialises
System::Globalization::CultureInfo’s default thread culture and UI culture from the platform’s ordered list of preferred
locales, unless your program has already set them. So once your game object exists you can read the UI language straight from
sharp-runtime’s CultureInfo:
#include "System/Globalization/CultureInfo.hpp"
std::string DetectSystemLocale() {
const auto& ui = System::Globalization::CultureInfo::getCurrentUICultureProperty();
std::string code = ui.getTwoLetterISOLanguageNameProperty(); // "cs", "en", ...
// "iv" is the invariant culture: the platform reported no usable locale
return (code.empty() || code == "iv") ? "en" : code;
}
If you also compile the optional CNA::Devices layer (-DCNA_DEVICES=ON), CNA::Devices::Locale::getPreferredLocalesProperty()
returns the platform’s whole ordered preference list, which is what you want for picking the best of the languages you ship.
The classic environment-variable approach still works where no Game exists yet, for example in a command-line tool:
#include <cstdlib>
#include <string>
std::string DetectSystemLocaleFromEnvironment() {
#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
#include "CNA/Logger.hpp"
#ifndef NDEBUG // debug builds (_DEBUG is MSVC-only; NDEBUG is the standard release marker)
// In Update(): watch for F5 to reload strings without restarting
void Update(GameTime& gt) override {
Game::Update(gt);
auto ks = Keyboard::GetState();
if (ks.IsKeyDown(Keys::F5) && !prevF5_) {
try {
localization_.Load(localization_.CurrentLang());
CNA::Logger::Info("Strings reloaded");
} catch (const std::exception& e) {
CNA::Logger::Warn(std::string("Reload failed: ") + e.what());
}
}
prevF5_ = ks.IsKeyDown(Keys::F5);
}
#endif