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 startTutorial 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