NoMoreRP Docs
Home

nmrp-locale

A localization (i18n) system for nanos world, shared across every package and reachable from Lua (server + client) and from the WebUI (JS) with the exact same translation tables.

What it is

nmrp-locale gives every package one shared translation store and one way to read it, whether you are in Lua or in a WebUI:

  • Per-script locales — each package declares its translations under its own namespace, so keys never collide between packages.
  • Shared locales — a reserved namespace (Locale.SHARED) reachable from every package. A key missing in a namespace automatically falls back to the shared one.
  • Web compatible — the client pushes the store + language to any WebUI via Locale.Attach(webui); the page loads locale.js and translates on the JS side, from the same tables.

Installation

Add nmrp-locale to your package’s packages_requirements:

[script] # or [game_mode]
    packages_requirements = [ "nmrp-locale" ]

The global Locale is then available everywhere — the Lua state is shared between packages.

Ready-to-copy usage snippets live in examples/ (per-script, server-per-player, WebUI). They are not loaded or shipped — pure reference.

Language model

The active language is per-player and client-owned. It cannot be chosen on the server. On the client, Shared/locale.lua wires it automatically: it adopts Client.GetLanguage() on load and follows the engine "LanguageChange" event — you never set it yourself.

  • Client / WebUILocale.Translate(...) uses the player’s current language.
  • Server — there is no single active language. Translate a player-facing string by passing that player’s language explicitly (the 4th argument).
  • Fallback — a server/realm-wide fallback (default "en"), used when a key is missing in the active language. Set it with Locale.SetFallback("fr").

Language codes

Codes are plain ISO 639-1 strings, optionally region-tagged (e.g. "en-US", "pt-BR"). Any code works at runtime — you are not limited to the list below.

  • Type — the LocaleLanguage alias (in Shared/locale.types.lua) enumerates the common codes for autocomplete while still accepting any string.
  • Runtime enumLocale.Languages (Lua) / window.Locale.languages (JS) map each supported code to its native display name, ideal for a language picker.
for code, name in pairs(Locale.Languages) do
    print(code, name); -- "fr" -> "Français", "ja" -> "日本語", ...
end
Object.entries(window.Locale.languages).forEach(([code, name]) => {
  // build <option value="fr">Français</option> ...
});

Supported out of the box: en, fr, de, es, it, pt, pt-BR, ru, pl, tr, nl, sv, da, fi, no, cs, hu, ro, el, uk, ja, ko, zh-CN, zh-TW, ar, th, vi, id.

Key resolution

Resolution goes from most specific to broadest. Each language is also tried by its base code ("en-US" -> "en"):

namespace[language] -> namespace[fallback] -> shared[language] -> shared[fallback] -> "the.key"

If nothing matches, the key itself is returned — missing translations are easy to spot in game.

Lua: per-script locales

Every package registers its own tables under a namespace. Nested tables become dotted keys.

-- In your package, e.g. Shared/locale.lua
local L <const> = Locale.Namespace("my-package");

L:Register("en", {
    menu = { title = "Settings", save = "Save" },
    welcome = "Welcome, {name}!",
});

L:Register("fr", {
    menu = { title = "Paramètres", save = "Enregistrer" },
    welcome = "Bienvenue, {name} !",
});

-- Usage (nested tables become dotted keys)
print(L:t("menu.title"));                -- "Settings" (active language = en)
print(L:t("welcome", { name = "Bob" })); -- "Welcome, Bob!"

Lua: shared locales

A curated built-in pack ships under Shared/locales/ and is registered into the shared namespace at boot, so universal keys exist out of the box:

-- Available immediately, in every package, in en/fr (more via contributions)
local L <const> = Locale.Namespace("other-package");
print(L:t("common.yes"));   -- "Yes" / "Oui"  (not in "other-package" -> shared)
print(L:t("common.cancel"));
print(Locale.Translate(Locale.SHARED, "time.today"));

You can also feed the shared namespace yourself at runtime — it merges:

Locale.Shared:Register("en", { common = { retry = "Retry" } });
Locale.Shared:Register("fr", { common = { retry = "Réessayer" } });

Built-in shared keys

common.*yes, no, ok, cancel, confirm, save, delete, edit, remove, add, create, close, back, next, previous, search, loading, settings, error, success, warning, info, enabled, disabled, none, all, name, description · time.*now, today, yesterday, tomorrow

Contributing a language to the shared pack

The shared namespace is global to every package, so the pack stays small and universal. To add or complete a language:

  1. Add Shared/locales/<code>.lua returning a LocaleTranslations table — mirror the keys of en.lua (the reference).
  2. Add one line in Shared/locales/Index.lua: load("<code>", "<code>.lua");.
  3. Keep keys prefixed and universal (common.*, time.*, unit.*). Anything game/package-specific belongs in that package’s own namespace, not here.

Adding a brand-new language code? The list of codes is duplicated in 4 places that must be kept in sync (there is no shared source between the Lua VMs and the JS bundle):

  • Shared/locale.types.lua — the LocaleLanguage alias (autocomplete)
  • Shared/locale.lua — the Locale.Languages table (code → native name)
  • Client/web/locale.js — the LANGUAGES const (code → native name)
  • Client/web/locale.d.ts — the LocaleLanguage type

Any ISO 639-1 code already works at runtime via Register() — updating these 4 only adds it to autocomplete and the Locale.Languages selector map.

Server: translate per player

On the server there is no active language, so pass the player’s language explicitly as the last argument:

local L <const> = Locale.Namespace("my-package");

-- Resolve the player's language however you store it (preference, DB, etc.).
local lang <const> = player:GetValue("language") or Locale.fallback;
Chat.SendMessage(player, L:t("welcome", { name = name }, lang));

Language change & events

On the client the active language follows the player automatically; you can read it and subscribe to changes:

-- Client side: the active language follows the player automatically.
local lang <const> = Locale.GetLanguage(); -- = Client.GetLanguage()

Locale.SetFallback("en"); -- fallback for missing keys

local off <const> = Locale.OnChange(function(language)
    print("Language changed:", language);
end);
-- off() to unsubscribe

Web (WebUI): same store, JS side

1. Lua client: attach the WebUI

local ui <const> = WebUI("MyUI", "file:///web/index.html");
Locale.Attach(ui); -- pushes the store + language, follows Register/SetLanguage

2. Web page: load locale.js

locale.js ships in this package at Client/web/locale.js. A WebUI resolves file:/// paths relative to the calling package’s own folder, so the script must sit next to your page: copy Client/web/locale.js into your own package’s WebUI folder (e.g. your Client/web/, next to index.html) and load it there.

TypeScript? Copy Client/web/locale.d.ts alongside it — it types the global window.Locale (and exports LocaleNamespace, LocaleLanguage, etc.).

<script src="locale.js"></script>
<script>
  const Locale = window.Locale;
  const L = Locale.namespace("my-package");

  function render() {
    document.querySelector("#title").textContent = L.t("menu.title");
    document.querySelector("#hi").textContent    = L.t("welcome", { name: "Bob" });
    document.querySelector("#yes").textContent   = Locale.t("common.yes"); // shared
  }

  // Re-render on store load / language change
  Locale.onChange(render);

  // Change the language from the UI (notifies Lua automatically)
  // Locale.setLanguage("fr");
</script>

locale.js auto-wires to the window.Events bridge in game: it requests the store on load (locale:request) and listens to locale:load / locale:language.

Browser dev (no game)

window.Events is absent out of game — feed the store manually:

window.Locale.load({
  language: "fr",
  fallback: "en",
  data: { "my-package": { fr: { "menu.title": "Paramètres" } } },
});

API reference

Lua — Locale

Authority: [Both] = callable on server and client · [Client] = client only (no-op on the server).

FunctionAuthorityDescription
Locale.Namespace(name)BothCached namespace object (per-script locales).
Locale.SharedBothReady-to-use shared namespace.
Locale.LanguagesBothMap of supported code -> native display name.
Locale.Register(ns, lang, tbl)BothRegister/merge translations (nested tables OK). Re-syncs WebUIs on client.
Locale.Translate(ns, key, params?, language?)BothTranslate with fallback + {name} interpolation. language overrides the active one (server per-player).
Locale.Has(ns, key, language?)BothDoes the key exist (ns or shared)?
Locale.SetLanguage(lang) / GetLanguage()BothActive language (client-driven; server = realm-wide default).
Locale.SetFallback(lang)BothFallback language.
Locale.OnChange(cb)BothListen to changes; returns an unsubscribe function.
Locale.Attach(webui) / Detach(webui)ClientWire a WebUI to the store.

Lua — namespace object

All [Both]: ns:Register(lang, tbl) · ns:Get(key, params?, language?) / ns:t(...) · ns:Has(key, language?)

JS — window.Locale

The WebUI runs client side only, so all JS functions are client/WebUI:

namespace(name) · t(key, params?) (shared) · translate(ns, key, params?) · has(ns, key) · setLanguage(lang) / getLanguage() · onChange(cb) · load(payload) · languages (code -> native name)

Interpolation

Tokens are {name} (named) or {1} (positional). A token without a matching value is left as-is (a debugging aid).

print(L:t("msg", { count = 3 })); -- "You have 3 message(s)"  from "You have {count} message(s)"

See also

MIT © 2026 JustGodWork.