Text
Localized strings from locale tables with a per-player fallback chain and {Name} interpolation, optionally answered by the player's Roblox Translator first.
- Realm
- Shared
- Require
ReplicatedStorage.ChloeKernel .Text - Since
- v0.7.0
- Source
- Text.luau
Text turns a table of locale strings into two calls, get and format, that never throw on a missing key or variable.
A key resolves through the player’s locale, then that locale’s language, then a default, and a key missing everywhere
comes back as the key itself, which a tester sees at once and can search for. Since 0.9.0
Source = "Translator" asks the player’s LocalizationService Translator first and keeps the tables as the fallback.
Text only localizes; player-written text that others will read goes through ChloeKernelServer.TextFilter.
Mental model
Section titled “Mental model”Text.attach(kernel, options) stores your Locales table as given and lowercases Default once ("en-us" when
unset). To resolve a player’s locale it tries, in order:
options.LocaleOf(player), if supplied. A string result is lowercased and used.- The player’s
LocaleIdwhenplayeris an Instance, read insidepcalland used when it is a non-empty string, lowercased. - A
LocaleIdstring field on a table, for spec doubles, lowercased. Default.
get(player, key) then walks a chain built from that locale:
- The locale itself, for example
"fr-fr". - Its language, the part before the first
-("fr"). A locale without a dash adds nothing here. Default.
The first Locales[candidate][key] that is not nil wins. If none has the key, get warns through
Logger.scope("Text") with [Text] no translation anywhere for "<key>", once per key for the life of the instance, and
returns the key. Untranslated UI shows Inventory.Full on screen: obviously wrong, easy to search for, never blank and
never an error in the middle of a frame.
format(player, key, vars?) interpolates the resolved string. Text.interpolate(template, vars?) is the pure core: it
replaces each {Name} placeholder with tostring(vars.Name). The name matches %w+, letters and digits only, so
{Player_Name} is not a placeholder. A placeholder stays literal when vars is nil, when the name is missing from
vars, or when its value is nil. Because it takes only a string and a table, interpolate is the part to unit test.
The Translator source
Section titled “The Translator source”With Source = "Translator", get and format try the player’s Roblox Translator before the tables:
- The first call for a player starts
LocalizationService:GetTranslatorForPlayerAsync(player)on a spawned thread, once per player.text:loadTranslator(player)starts it earlier. While it loads, the tables answer. - When it arrives, Text keeps it and publishes
Text.TranslatorReady(player). From then ongetreturnsTranslator:FormatByKey(key, {})andformatreturnsTranslator:FormatByKey(key, vars), which fills the same{Name}placeholders. FormatByKeythrows for a key the Translator lacks or a missing argument. Text catches it and the tables answer.- A load that fails is remembered and never retried; that player’s lookups stay on the tables.
A nil player always uses the tables. Translators are held in a table with weak keys, so a departed player’s
Translator can be collected.
Send keys, localize at render
Section titled “Send keys, localize at render”Text attaches the same way on server and client, from tables that ship in one shared module. That enables the pattern
to follow: replicate keys and resolve them on the client. A server system that tells a client “your bag is full” sends
"Inventory.Full", and the client calls get or format when it draws. A key is a few bytes where a sentence is not,
and only the client needs its own player’s locale; resolving on the server would mean shipping every locale’s copy or
tracking a locale the server has no other use for.
Server code that only decides things (grant an item, check a condition) never touches Text.
--!strict-- Locales: every UI string, by locale, then key.
return { ["en-us"] = { ["Inventory.Full"] = "Your bag is full", ["Coins"] = "{Count} coins", }, -- A language table serves fr-fr, fr-ca and every other French locale ["fr"] = { ["Inventory.Full"] = "Votre sac est plein", },}--!strict-- TextController: the client's localized strings.
local Players = game:GetService("Players")local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Locales = require(ReplicatedStorage.Game.Config.Locales)local Text = require(ReplicatedStorage.ChloeKernel.Text)
local TextController = { Name = "TextController",}
function TextController.init(self: any, kernel: any) self.Strings = Text.attach(kernel, { Locales = Locales, Source = "Translator" })end
function TextController.start(self: any) self.Strings:loadTranslator(Players.LocalPlayer) -- start the cloud table load now, not on the first lookupend
function TextController.stop(_self: any) end
return TextController--!strict-- Hud screen: localized bag and coin labels.
local Players = game:GetService("Players")
local Hud = {}
function Hud.new(context: any, gui: ScreenGui) local Kernel = context.Kernel local Strings = context.Controllers("TextController").Strings local LocalPlayer = Players.LocalPlayer local Scope = Kernel:createScope() local Bag = gui:FindFirstChild("BagFull", true) local Coins = gui:FindFirstChild("Coins", true)
local function render() -- LocaleId "fr-fr": "Votre sac est plein" from the fr table, "100 coins" from en-us (no French Coins entry) if Bag and Bag:IsA("TextLabel") then Bag.Text = Strings:get(LocalPlayer, "Inventory.Full") end if Coins and Coins:IsA("TextLabel") then Coins.Text = Strings:format(LocalPlayer, "Coins", { Count = 100 }) end end
render() Scope:add(Kernel.Bus:subscribe("Text.TranslatorReady", function(_topic, player) if player == LocalPlayer then render() -- redraw with the Translator's strings end end))
return { Modal = false, StartsOpen = true, open = function(_self) gui.Enabled = true end, close = function(_self) gui.Enabled = false end, destroy = function(_self) Scope:destroy() end, }end
return HudStrings:get(LocalPlayer, "Shop.Welcome") with no entry anywhere returns "Shop.Welcome" and warns once.
| Member | Description |
|---|---|
Text.attach(kernel, options) -> Text |
Asserts options.Locales is a table and Source is "Tables" or "Translator". |
text:get(player, key) -> string |
The Translator’s string when one is loaded and knows the key, otherwise the table chain. Missing everywhere: warns once per key and returns key. |
text:format(player, key, vars?) -> string |
The Translator’s FormatByKey(key, vars) when it succeeds, otherwise Text.interpolate over the table result. |
text:loadTranslator(player) |
Starts loading the player’s Translator now. Does nothing unless Source = "Translator", or when a load already started. Added in 0.9.0. |
Text.interpolate(template, vars?) -> string |
Pure. Replaces {Name} placeholders (%w+ names) from vars. A nil vars, a missing name or a nil value leaves the placeholder as written. |
Options:
| Field | Default | Description |
|---|---|---|
Locales |
required | { [locale]: { [key]: string } } with lowercase locale names. |
Default |
"en-us" |
Fallback locale. Lowercased. |
LocaleOf |
none | (player) -> string?. Overrides the LocaleId read for the table chain. |
Source |
"Tables" |
"Translator" asks the player’s Roblox Translator first. Added in 0.9.0. |
Localization |
LocalizationService |
Spec seam for the service that provides Translators. |
Bus topics
Section titled “Bus topics”| Topic | Payload | Fired |
|---|---|---|
Text.TranslatorReady |
(player) |
Once per player, when GetTranslatorForPlayerAsync returned a Translator. Only with Source = "Translator" and a kernel that has a Bus. |
Design notes
Section titled “Design notes”- The warning is once per key, not per call: the first lookup of a missing key anywhere warns, and every later lookup of that key, for any player, returns it silently. It flags a first sighting and does not count occurrences.
- Locale table names must be lowercase.
Default,LocaleId,LocaleOfresults and the language split are all lowercased before lookup, so a table named"fr-FR"is never found.