Skip to content

Text

Text turns a nested table of locale strings into two calls — get and format — that never throw and never render blank. A locale table is just { [locale] = { [key] = string } }; Text.attach(kernel, { Locales, Default? }) wraps it with a resolution chain that falls back through a player’s exact locale, that locale’s language, and a default, and a missing key still returns something a tester can see is wrong instead of an empty label or a script error.

Text.attach lowercases Default once ("en-us" if you don’t set one) and stores your Locales table as-is. Resolving a player’s locale (localeOf, internal) checks three sources in order:

  1. options.LocaleOf(player), if you supplied one — an override hook, useful for spec doubles or custom locale logic. Its return value is lowercased and used if it’s a string.
  2. The player’s own LocaleId — read via pcall (so a non-Instance or an odd duck-typed table can’t error the lookup), lowercased.
  3. A duck-typed fallback for spec doubles: any table with a string LocaleId field, lowercased, for test code that fakes a player without being a real Instance.
  4. Failing all of that, Default.

text:get(player, key) builds a resolution chain from that single locale and walks it in order, verified from source:

  1. The resolved locale exactly (e.g. "fr-fr").
  2. That locale’s language half, if it has a dash — "fr-fr" splits at the first - to "fr". A locale with no dash contributes nothing here.
  3. Default.

For each candidate, get looks up Locales[candidate][key] and returns the first non-nil hit. If no candidate table has the key, get warns — once per key, tracked in an internal WarnedKeys set, not once per call — and returns key itself. That’s a deliberate fallback, not a placeholder: untranslated UI reads as its own key (Inventory.Full shown literally on screen), which is visibly wrong and immediately greppable, but it never renders blank and never throws mid-frame.

text:format(player, key, vars?) is just Text.interpolate(text:get(player, key), vars). Text.interpolate is the pure, kernel-free core: it matches {Name}-style placeholders (word characters only — letters, digits, underscore) and replaces each from vars. Two cases leave a placeholder untouched, confirmed from source:

  • vars is nil entirely — interpolate returns the template unmodified, no substitution attempted at all.
  • vars is given but doesn’t have that name, or the value is explicitly nil — the placeholder’s literal text ({Unknown}) is written back, not blanked.

Every other placeholder is replaced with tostring(vars[name]). Because interpolate takes a template string and a plain table — no player, no kernel — it’s the piece you unit test directly.

The wire pattern: keys over the network, not strings

Section titled “The wire pattern: keys over the network, not strings”

Text is required from a shared module and attaches identically on the server and the client — the source header shows the same Text.attach(kernel, {...}) call either side would make. The pattern this enables: replicate keys, not localized strings. A server system that tells a client “your bag is full” should send the key "Inventory.Full", not the resolved English sentence — and the client calls get/format itself at render time. Two reasons:

  • Bandwidth. A key is a handful of bytes; a resolved sentence (and every locale’s version of it, if you tried to precompute) is not — this is the same instinct as replicating state, not narration.
  • The client knows its own player’s locale; the server doesn’t need to. get(player, key) reads player.LocaleId — for UI, the only correct machine to resolve that on is the one the player is actually looking at. Resolving on the server and shipping text would mean either every locale’s copy crosses the wire so the client can pick, or the server has to know the client’s locale for a purely client-side rendering decision it has no other reason to care about.
local Text = require(game:GetService("ReplicatedStorage").ChloeKernel.Text)
local Locales = Text.attach(kernel, {
Default = "en-us",
Locales = {
["en-us"] = {
["Inventory.Full"] = "Your bag is full",
["Coins"] = "{Count} coins",
},
["fr-fr"] = {
["Inventory.Full"] = "Votre sac est plein",
-- "Coins" intentionally omitted: falls back to en-us
},
},
})
-- A player with LocaleId "fr-fr":
Locales:get(FrenchPlayer, "Inventory.Full") --> "Votre sac est plein"
Locales:format(FrenchPlayer, "Coins", { Count = 100 }) --> "100 coins" (fell back to en-us)
-- A player with LocaleId "de-de" and no de-de or de table registered:
Locales:get(GermanPlayer, "Inventory.Full") --> "Your bag is full" (Default)
Locales:get(GermanPlayer, "Shop.Welcome") --> "Shop.Welcome" (missing everywhere; warns once)

Server code that only needs to decide something (grant an item, check a condition) never touches Text at all — it publishes the key. Client-side UI code owns the get/format call for whatever it’s about to render, using the same shared Locales table required from the same module.

Member Description
Text.attach(kernel, options) → Text options.Locales: {[locale]: {[key]: string}} (required), options.Default: string? fallback locale ("en-us" if omitted, lowercased), options.LocaleOf: ((player) -> string?)? override for locale resolution
text:get(player, key) → string Resolves through the player’s locale, its language half, then Default. Missing everywhere: warns once per key, returns key
text:format(player, key, vars?) → string get(player, key) piped through Text.interpolate
Text.interpolate(template, vars?) → string Pure. Replaces {Name} placeholders from vars; a nil vars, a missing name, or a nil value all leave the placeholder literal

Locale table keys must be lowercase to match what Text looks them up with — Default is lowercased at attach, and every resolved locale (from LocaleId, from LocaleOf, or from the language-half split) is lowercased before the lookup. Roblox’s LocaleId values ("en-us", "fr-fr") are already lowercase in practice, but a custom LocaleOf that returns mixed case still resolves correctly since Text normalizes it either way.