A lot of game and live-ops work has the same shape: you have some
config — a shop layout, an event, a card — and you want to turn
it into the exact payload a particular player should see, in their
language, on their platform. SCUA has a built-in render for
this. You write the config once, with placeholders, and
render bakes it against the data you pass in.
The defining idea: the config never knows who's rendering
it. It asks for a value called name; it doesn't
know there's a "player" with a name field. You supply the
data at render time, and the config stays a clean, portable description
that you can ship, store, and reuse.
let player = { name = "Fred", level = 7 }
let card = {
heading = "Welcome, @{external/name}!",
subtitle = "Level @{external/level} trader",
}
let baked = render(card, "en_US", player)
print(baked.heading) -- Welcome, Fred!
print(baked.subtitle) -- Level 7 trader
render(source, locale, external) walks the
source, resolves every @{...} reference, and
returns a fresh table. locale is the language to bake for
(more on that below). external is the data you're rendering
against.
#The two roots:
self and external
Every reference starts with one of two roots:
self/...reads from the source itself — useful for a value defined elsewhere in the same config.external/...reads from the data you passed in.
let card = {
brand = "Coin Shop",
title = "@{self/brand} — for @{external/who}",
}
print(render(card, "en_US", { who = "Ana" }).title) -- Coin Shop — for Ana
external can be a single table, or a list of
tables merged together with later entries winning. That lets
you layer shared defaults under per-player data without the config
knowing the difference:
let common = { currency = "coins" }
let player = { name = "Fred" }
let card = { who = "@{external/name}", money = "@{external/currency}" }
print(render(card, "en_US", [common, player]).who) -- Fred
print(render(card, "en_US", [common, player]).money) -- coins
A reference that doesn't resolve is a loud error, not a silent blank
— you'll get
render: reference '@{external/nope}' did not resolve.
That's deliberate: a missing value in a payload is a bug you want to
hear about.
#Two kinds of hole
A @{...} hole behaves differently depending on whether
it's the whole string or part of it:
- Embedded in text, it's substituted as a string:
"Hi @{external/name}!"→"Hi Fred!". - Alone — the entire value is one hole — it yields the referenced value itself, not its text. That can be a number, or a whole subtree:
let common = { catalog = { base = { layout = "grid", slots = 8 } } }
let card = { config = "@{external/catalog/base}" } -- pulls the whole subtree
let baked = render(card, "en_US", common)
print(baked.config.layout) -- grid
print(baked.config.slots) -- 8
#Directive blocks
For anything beyond a simple reference, a table with a reserved
@-key becomes a directive. It's
still ordinary data — @ is just a marker
render recognizes.
#@ref and @raw
@ref is the explicit form of a lone @{path}
— clearer when you're pulling a whole subtree. @raw emits
its value verbatim, so @{...} and @-keys
inside are kept literally (the escape hatch for data that really does
contain the render sigils):
let common = { catalog = { base = { layout = "grid" } } }
let card = {
config = { @ref = "external/catalog/base" },
help = { @raw = { note = "Use @{token} at checkout" } },
}
let b = render(card, "en_US", common)
print(b.config.layout) -- grid
print(b.help.note) -- Use @{token} at checkout (left literal)
#@when —
include a subtree conditionally
@when includes a whole subtree only when a value is
truthy. A missing value is falsy, so a gated feature
simply doesn't appear — and with no @else, the key is
dropped from the output entirely:
let catalogs = { premium = { perk = "vip_lounge" } }
let shop = {
premium = {
@when = "external/flags/has_premium",
@then = { @ref = "external/premium" },
},
}
let whale = render(shop, "en_US", [catalogs, { flags = { has_premium = true } }])
print(whale.premium.perk) -- vip_lounge
let free = render(shop, "en_US", [catalogs, { flags = { has_premium = false } }])
print(free.premium ?? "(none)") -- (none) — the key was dropped
#@match — pick a variant
@match selects a branch by a value, with
@default as the fallback. An unmatched value with no
@default is a loud error:
let shop = {
theme = {
@match = "external/tier",
whale = { skin = "gold" },
@default = { skin = "standard" },
},
}
print(render(shop, "en_US", { tier = "whale" }).theme.skin) -- gold
print(render(shop, "en_US", { tier = "free" }).theme.skin) -- standard
#@each — map over a list
@each maps an @do template over a list,
binding each element to a name (@as). Inside the template,
that name is a third reference root, alongside self and
external:
let world = { items = [ { id = 1, kind = "sword" }, { id = 2, kind = "shield" } ] }
let menu = {
rows = { @each = "external/items", @as = "it",
@do = { id = "@{it/id}", label = "@{it/kind} for @{external/name}" } },
}
let m = render(menu, "en_US", [world, { name = "Fred" }])
print(m.rows[0].label) -- sword for Fred
print(m.rows[1].label) -- shield for Fred
@each is a bounded map — never an open-ended loop — so a
config you didn't write can't make the renderer hang. (See Limits.)
#Localization
Localized strings live next to your data, not in a
separate resource file. A localized({...}) value is a map
from locale tag to string. At render time it collapses to the active
locale — the second argument to render:
let card = {
title = localized({ en_US = "Welcome", es_ES = "Bienvenido", fr_FR = "Bienvenue" }),
}
print(render(card, "es_ES", {}).title) -- Bienvenido
print(render(card, "fr_FR", {}).title) -- Bienvenue
It collapses wherever it lands — inline as above, or pulled through a
reference. Keys use underscores (en_US), and a BCP-47 tag
like "es-ES" is normalized to match. A tag with no exact
entry falls back to its primary subtag, so "es" finds an
es_ES string. A localized string can itself contain
@{...} holes, which resolve after the collapse:
let event = {
body = localized({
en_US = "Welcome, @{external/name}!",
es_ES = "¡Bienvenido, @{external/name}!",
}),
line = "@{self/body}",
}
print(render(event, "es-ES", { name = "Ana" }).line) -- ¡Bienvenido, Ana!
#Dates, numbers, and case:
@fmt
A hole can carry a formatter after a :. The headline is
locale-aware dates — the month name and the field order follow the
render locale:
import time
let event = { d = time.of(2023, 2, 1), when = "Starts @{self/d:date.long}" }
print(render(event, "en_US", {}).when) -- Starts February 1, 2023
print(render(event, "es_ES", {}).when) -- Starts 1 de febrero de 2023
print(render(event, "de_DE", {}).when) -- Starts 1. Februar 2023
The formatters are date / date.long /
date.short / date.iso, plus upper
and lower. date.short is a locale-ordered
numeric date (2/1/2023 in the US, 1.2.2023 in
Germany). There's also a block form,
{ @fmt = "upper", value = "@{external/name}" }.
Locale-aware month names currently ship for English, Spanish, French,
German, Italian, and Portuguese, with English as the fallback for other
locales; more languages are on the way.
#Plurals and gender:
@msg
For text that changes with a count or a gender, put an ICU
MessageFormat string in a localized value and evaluate it with
@msg. The right plural form is chosen per locale;
# is the number:
let box = {
loc = { summary = localized({
en_US = "You have {count, plural, =0 {no mail} one {# message} other {# messages}}.",
es_ES = "Tienes {count, plural, one {# mensaje} other {# mensajes}}.",
}) },
line = { @msg = "self/loc/summary", args = { count = "@{external/unread}" } },
}
print(render(box, "en_US", { unread = 1 }).line) -- You have 1 message.
print(render(box, "en_US", { unread = 0 }).line) -- You have no mail.
print(render(box, "es_ES", { unread = 3 }).line) -- Tienes 3 mensajes.
select works the same way for gender or any string-keyed
choice: {g, select, female {Ms.} other {Mx.}}. This is the
common subset of ICU — {name}, plural (with
=N exact cases), and select, nestable.
Languages with finer plural categories (Polish, Russian, Arabic) and the
more exotic ICU features are a planned expansion, behind the same
@msg.
#Keeping the output clean:
@strip
The localized strings you reference are authoring scaffolding — you
usually don't want the whole pool of languages shipped in the baked
output. List those fields in @strip and they're pruned.
They stay addressable (references resolve against the source), so your
other fields still find them:
let event = {
@strip = ["loc"],
loc = { title = localized({ en_US = "Sale", es_ES = "Oferta" }) },
title = "@{self/loc/title}",
id = 42,
}
let b = render(event, "es_ES", {})
print(b.title) -- Oferta
print(b.id) -- 42
print(b.loc ?? "(gone)") -- (gone)
#Knowing what was read:
render_tracked
When you cache baked payloads, you want to invalidate a cached result
only when something it actually read changes.
render_tracked returns the output plus the
read-set: every reference that was resolved, and
which input supplied it.
let common = { catalog = { base = { x = 1 } } }
let player = { name = "Fred" }
let src = { who = "@{external/name}", cat = { @ref = "external/catalog/base" } }
let r = render_tracked(src, "en_US", [common, player])
print(r.output.who) -- Fred
for rd in r.reads do print(rd.ref .. " <- " .. tostring(rd.origin)) end
-- external/name <- 1 (came from input 1, the player)
-- external/catalog/base <- 0 (came from input 0, common)
origin is "self" for a self-reference, or
the index in the external list for an external one. The
config still only ever sees self/external; the
renderer works out which underlying input each value came from, so you
can cache against the source and invalidate precisely when a given input
changes.
#Limits
render is pure and total — no I/O, no clock, the same
inputs always produce the same output — and it runs under hard limits so
that even a config you didn't write can't exhaust memory or hang: a
maximum nesting depth, a total output-node budget, and a cap on total
@each expansion. Hitting a limit aborts the render with a
clear error rather than degrading. These are generous; ordinary payloads
never come close.
#See also
examples/render.scua— config baking, end to end.examples/localization.scua— localized strings, dates, plurals, and@strip.- Dates and times — the
datetimetype@fmtformats. - Set the locale and
timezone — where
sys.locale()comes from (a sensible default to pass as the render locale).