Tutorial 98: Localization and Multiple Languages

CNA — C++ XNA 4.0 reimplementation

ℹ

What you’ll learn

  • A JSON string table per language behind a LocalizationManager.
  • Getting Unicode glyphs into a SpriteFont and 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