Skip to content

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.

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:

  1. options.LocaleOf(player), if supplied. A string result is lowercased and used.
  2. The player’s LocaleId when player is an Instance, read inside pcall and used when it is a non-empty string, lowercased.
  3. A LocaleId string field on a table, for spec doubles, lowercased.
  4. Default.

get(player, key) then walks a chain built from that locale:

  1. The locale itself, for example "fr-fr".
  2. Its language, the part before the first - ("fr"). A locale without a dash adds nothing here.
  3. 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.

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 on get returns Translator:FormatByKey(key, {}) and format returns Translator:FormatByKey(key, vars), which fills the same {Name} placeholders.
  • FormatByKey throws 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.

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.

src/Shared/Game/Config/Locales.luau
--!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",
},
}
src/Client/Game/Controllers/TextController.luau
--!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 lookup
end
function TextController.stop(_self: any) end
return TextController
src/Client/Game/Screens/Hud.luau
--!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 Hud

Strings: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.
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.
  • 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, LocaleOf results and the language split are all lowercased before lookup, so a table named "fr-FR" is never found.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.