Localization in Refuge: Why I ditched Unity's built-in system for Tolgee (and never looked back!)

Localization in Refuge: Why I ditched Unity's built-in system for Tolgee (and never looked back!)

Why you should consider localization from the start of your project

Many developers push localization to the very end, thinking it'll just be a quick "Ctrl+F" to replace all hardcoded strings. Spoiler alert: it's never that easy.

Why? Because localization isn't just about text, it's everywhere:

  • UI buttons.
  • Dialogues and item descriptions.
  • Settings and menus.
  • Notifications and event logs.
  • And occasionally, even inside code that isn't supposed to contain hardcoded text (hello, mysterious error messages!).

If you don't structure localization early on, you'll end up with a scattered mess of strings everywhere, leading to a painful last-minute refactor. Result? An absolute nightmare.

That's why I've built a modular localization system right from the start, making sure every new feature seamlessly supports multiple languages without future headaches.

Why not use Unity's built-in localization?

Unity does offer a built-in localization system, but it comes with some pretty serious drawbacks for an indie project like Refuge:

  • Rigid system: It heavily relies on pre-structured tables in ScriptableObjects, limiting flexibility.
  • Cumbersome translation management: Adding new languages or updating texts isn't very efficient, especially for indie development.
  • No integration with external translation tools: Automating sync with external tools like Tolgee isn't possible without significant custom work.

For these reasons, I opted to roll out my own straightforward system, tailored exactly to my project's needs.

My custom localization system

I designed my localization solution around two main classes: Translator and LocalizationItem.

Translator.cs: The mastermind behind translations

The Translator acts as the brain of the operation. It translates keys into readable text depending on the active language. Think of it as my personal translation server, but without a sketchy AI messing things up.

Here's how it works:

  • It stores available LocalizationItems neatly in a dictionary for speedy access.
  • It receives a text key and tries to translate it based on the current language.
  • If a translation isn't found, it returns the original key (because displaying [MISSING_TEXT] everywhere is just ugly).
  • It also handles language changes gracefully by publishing an event through EventBus, ensuring the UI updates automatically.
using System.Collections.Generic;
using Valcriss.Scripts.Events;
using Valcriss.Scripts.Tools;

namespace Valcriss.Scripts.Localization
{
    public class Translator
    {
        private string language;
        private readonly Dictionary<string, LocalizationItem> languageDictionary;

        public Translator(List<LocalizationItem> languages, string language)
        {
            this.language = language;
            languageDictionary = new Dictionary<string, LocalizationItem>();
            foreach (LocalizationItem localizationItem in languages)
            {
                languageDictionary.Add(localizationItem.GetKey(), localizationItem);
            }
        }

        public string GetTranslation(string key)
        {
            if (language == null) return key;
            return !languageDictionary.TryGetValue(language, out LocalizationItem localizationItem) ? key : localizationItem.GetLanguageData().GetValueOrDefault(key, key);
        }

        public string GetLanguage()
        {
            return language;
        }

        public void SetLanguage(string newLanguage)
        {
            language = newLanguage;
            EventBus.Publish(new LanguageChangedEvent(newLanguage));
        }
    }
}

LocalizationItem.cs: The magical storage box for translations

The LocalizationItem is the handy ScriptableObject that neatly stores all translations in JSON format directly within Unity. Because who doesn't love the idea of magical objects containing all your texts?

Here's how it works:

  • key: Unique identifier for each language (e.g., "en", "fr", "es"... no surprises here).
  • languageData: A straightforward JSON text file containing all translations.
  • JSON content is loaded only when necessary, with caching to keep performance smooth.
  • Uses a regex to parse JSON, simple yet effective (and occasionally frustrating if you miss a comma somewhere!).
using System.Collections.Generic;
using System.Text.RegularExpressions;
using UnityEngine;

namespace Valcriss.Scripts.Localization
{
    [CreateAssetMenu(fileName = "localization", menuName = "Refuge/Localization/Language")]
    public class LocalizationItem : ScriptableObject
    {
        [SerializeField] private string key;
        [SerializeField] private TextAsset languageData;

        public string GetKey() => key;
        public Dictionary<string,string> GetLanguageData() => LoadLanguageData();

        private Dictionary<string, string> languageDictionary;

        private Dictionary<string, string> LoadLanguageData()
        {
            if (languageDictionary != null) return languageDictionary;
            languageDictionary = ParseJsonToDictionary(languageData.text);
            return languageDictionary;
        }

        private Dictionary<string, string> ParseJsonToDictionary(string jsonText)
        {
            Dictionary<string, string> dictionary = new();

            string pattern = "\"(.*?)\"\s*:\\s*\"(.*?)\"";
            MatchCollection matches = Regex.Matches(jsonText, pattern);

            foreach (Match match in matches)
            {
                if (match.Groups.Count == 3)
                {
                    string translationKey = match.Groups[1].Value;
                    string translationValue = match.Groups[2].Value;
                    dictionary[translationKey] = translationValue;
                }
            }

            return dictionary;
        }
    }
}

This keeps each language organized and manageable, even for someone like me who despises spending hours tracking down lost keys.

Tolgee: A lazy translator's best friend

Tolgee is an open-source translation management tool offering an intuitive interface and API that lets you automate importing/exporting translation files:

  • Web interface for easy translation editing.
  • Versioning and collaboration (great for solo devs who still want history).
  • CLI integration for seamless file syncing.

Configuring Tolgee in Refuge

I use a self-hosted Tolgee server configured via a .tolgeerc file:

{
  "$schema": "https://docs.tolgee.io/cli-schema.json",
  "projectId": 42,
  "format": "JSON_TOLGEE",
  "apiUrl": "https://my-awesome-secret-translation-server.com/",
  "apiKey": "(Did you seriously think I'd leave this here? Nice try.)",
  "pull": {
    "path": "./Assets/Objects/Localization"
  }
}

Automating with a Batch script

I also set up localize.bat for instant synchronization and perfect file placement:

npm install --global @tolgee/cli
tolgee pull

This script automatically places JSON files in the right Unity folder, no manual copying!

Conclusion

Combining my custom system with Tolgee gives me a flexible, automated, and effortless localization workflow. No more nightmare file updates, just smooth sailing and complete control!


Pourquoi penser à la localisation dès le début du projet ?

Beaucoup de développeurs repoussent la localisation à la toute fin du développement, pensant qu'il suffit de faire un simple "Ctrl+F" sur les chaînes de texte et de remplacer les valeurs en dur. Spoiler alert : ce n'est jamais aussi simple.

Pourquoi ? Parce que la traduction ne concerne pas seulement du texte. Elle est partout :

  • Les boutons de l'UI.
  • Les dialogues et descriptions d'objets.
  • Les paramètres et menus.
  • Les notifications et journaux d'événements.
  • Et parfois, même dans du code qui n'est pas censé contenir du texte brut (coucou les erreurs hardcodées).

Si on ne structure pas la localisation dès le début, on se retrouve avec un projet où les chaînes sont dispersées un peu partout, et où il faut refactoriser à la dernière minute. Résultat ? Un enfer absolu.

C'est pourquoi j'ai choisi d'intégrer un système de localisation modulaire dès le départ, afin que chaque nouvelle feature soit directement compatible avec plusieurs langues sans avoir à tout revoir plus tard.

Pourquoi ne pas utiliser la localisation intégrée d'Unity ?

Unity propose un système de localisation, mais il présente plusieurs limitations pour un projet comme Refuge :

  • Rigidité du système : Il repose sur des tableaux de valeurs pré-structurés dans des fichiers ScriptableObjects, ce qui limite la flexibilité.
  • Gestion des traductions peu ergonomique : L'ajout de nouvelles langues ou la modification des textes n'est pas optimisé, surtout pour un développement indépendant.
  • Pas d'intégration avec des outils de traduction : Impossible d'automatiser la synchronisation avec un outil externe comme Tolgee sans une solution personnalisée.

Pour ces raisons, j'ai choisi de développer mon propre système de localisation, simple et adapté à mes besoins.

Mon système de localisation custom

J'ai conçu un système de localisation autour de deux classes principales : Translator et LocalizationItem.

Translator.cs : Gestion des traductions

Le Translator est l'esprit de notre système de localisation. Son rôle est de traduire les clés en texte lisible en fonction de la langue active. Il fonctionne comme un serveur de traduction personnel mais sans IA louche qui déforme les phrases.

Voici comment ça marche :

  • Il stocke les LocalizationItem disponibles dans un joli dictionnaire permettant d'accéder rapidement aux traductions.
  • Il reçoit une clé de texte et tente de la traduire en fonction de la langue actuelle.
  • S'il ne trouve pas la traduction, il renvoie simplement la clé d'origine (parce qu'afficher [MISSING_TEXT] partout, c'est moche et pas pratique).
  • Il gère aussi le changement de langue en publiant un événement sur l'EventBus, histoire que l'UI se mette à jour proprement sans avoir à fouiller partout.
using System.Collections.Generic;
using Valcriss.Scripts.Events;
using Valcriss.Scripts.Tools;

namespace Valcriss.Scripts.Localization
{
    public class Translator
    {
        private string language;
        private readonly Dictionary<string, LocalizationItem> languageDictionary;

        public Translator(List<LocalizationItem> languages, string language)
        {
            this.language = language;
            languageDictionary = new Dictionary<string, LocalizationItem>();
            foreach (LocalizationItem localizationItem in languages)
            {
                languageDictionary.Add(localizationItem.GetKey(), localizationItem);
            }
        }

        public string GetTranslation(string key)
        {
            if (language == null) return key;
            return !languageDictionary.TryGetValue(language, out LocalizationItem localizationItem) ? key : localizationItem.GetLanguageData().GetValueOrDefault(key, key);
        }

        public string GetLanguage()
        {
            return language;
        }

        public void SetLanguage(string newLanguage)
        {
            language = newLanguage;
            EventBus.Publish(new LanguageChangedEvent(newLanguage));
        }
    }
}

LocalizationItem.cs : Stockage des traductions

Le LocalizationItem est le conteneur pratique qui stocke toutes les traductions sous forme de JSON dans Unity. C'est un ScriptableObject parce que j'aime bien l'idée de stocker mes traductions comme des objets magiques directement dans mon projet Unity.

Son fonctionnement en détail :

  • key : identifiant unique pour chaque langue (ex : "fr", "en", "es"… pas de surprise ici).
  • languageData : un simple fichier texte JSON qui contient toutes les traductions.
  • Charge le JSON uniquement lorsque nécessaire, avec un cache intégré pour ne pas plomber les performances.
  • Utilise une expression régulière pour parser le JSON, simple mais efficace (et parfois frustrant quand on oublie une virgule quelque part…)
using System.Collections.Generic;
using System.Text.RegularExpressions;
using UnityEngine;

namespace Valcriss.Scripts.Localization
{
    [CreateAssetMenu(fileName = "localization", menuName = "Refuge/Localization/Language")]
    public class LocalizationItem : ScriptableObject
    {
        [SerializeField] private string key;
        [SerializeField] private TextAsset languageData;

        public string GetKey() => key;
        public Dictionary<string,string> GetLanguageData() => LoadLanguageData();

        private Dictionary<string, string> languageDictionary;

        private Dictionary<string, string> LoadLanguageData()
        {
            if (languageDictionary != null) return languageDictionary;
            languageDictionary = ParseJsonToDictionary(languageData.text);
            return languageDictionary;
        }

        private Dictionary<string, string> ParseJsonToDictionary(string jsonText)
        {
            Dictionary<string, string> dictionary = new();

            string pattern = "\"(.*?)\"\s*:\\s*\"(.*?)\"";
            MatchCollection matches = Regex.Matches(jsonText, pattern);

            foreach (Match match in matches)
            {
                if (match.Groups.Count == 3)
                {
                    string translationKey = match.Groups[1].Value;
                    string translationValue = match.Groups[2].Value;
                    dictionary[translationKey] = translationValue;
                }
            }

            return dictionary;
        }
    }
}

Ainsi, chaque langue reste bien organisée et facile à maintenir, même pour quelqu'un comme moi qui déteste perdre du temps à chercher des clés égarées.

Tolgee, l'allié des traducteurs fainéants

Tolgee est un outil open-source de gestion des traductions, offrant une interface intuitive et une API permettant d'automatiser l'import/export des fichiers de traduction. Il permet notamment :

  • Une interface web pour ajouter/modifier les traductions facilement.
  • Un système de versioning et de collaboration (pratique pour un dev solo qui veut quand même garder un historique).
  • Une intégration CLI pour automatiser la synchronisation des fichiers.

Configuration de Tolgee dans Refuge

J'utilise un serveur Tolgee auto-hébergé et une configuration via un fichier .tolgeerc :

{
  "$schema": "https://docs.tolgee.io/cli-schema.json",
  "projectId": 42,
  "format": "JSON_TOLGEE",
  "apiUrl": "https://my-awesome-secret-translation-server.com/",
  "apiKey": "(Did you seriously think I'd leave this here? Nice try.)",
  "pull": {
    "path": "./Assets/Objects/Localization"
  }
}

Automatisation avec un script Batch

J'ai aussi créé un localize.bat pour synchroniser les traductions en un clic et placer directement les fichiers au bon endroit dans le projet Unity :

npm install --global @tolgee/cli
tolgee pull

Ce script télécharge automatiquement les fichiers JSON depuis Tolgee et les dépose bien proprement dans le dossier de localisation de Refuge. Pas besoin de les copier à la main, tout est automatique !

Utilisation de l'interface de Tolgee avec un provider de traduction

L'interface web de Tolgee me permet d'ajouter les traductions directement depuis mon navigateur, avec une fonctionnalité ultra-pratique : l'intégration d'un provider de traduction automatique.

Voici comment je procède :

  1. J'ajoute une nouvelle clé dans Tolgee.
  2. Je tape la traduction en français (ma langue de base).
  3. Je laisse Tolgee générer automatiquement les traductions en anglais, allemand, espagnol, etc., grâce à l'intégration avec DeepL/Google Translate.
  4. Je corrige les erreurs éventuelles (parce que les IA ne sont pas toujours très inspirées, surtout sur des termes techniques).
  5. Je valide et synchronise dans Unity avec un simple localize.bat.

Résultat ? En quelques minutes, toutes mes traductions sont intégrées dans le jeu, sans douleur.

Conclusion

En combinant mon système custom de localisation avec Tolgee, j'obtiens une solution flexible et automatisée qui me permet de gérer les traductions sans effort tout en gardant le contrôle total. Finies les galères de mise à jour des fichiers de langue, place à un workflow plus efficace !