SCUA

Manual

Rendering and localization

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 datetime type @fmt formats.
  • Set the locale and timezone — where sys.locale() comes from (a sensible default to pass as the render locale).