One of the reasons teams reach for a scripting language in a game is to define data as code — levels, item tables, dialogue trees, tuning — where you call helper functions and compose pieces while you build the data. SCUA is good at this, and brings two things a plain data format (JSON/TOML) and even Lua can't match: free persistence and a capability sandbox.
#A data module is just a module
A data file is an ordinary SCUA file: define helpers and constants at
the top, build the data, and return it. That's the same module mechanism as any other file — there's no
special data mode or directive.
-- levels/world_01.scua
import math
fn pos(x, y) return { x = x, y = y } end
const TILE = 32
return {
name = "World 1",
size = { w = 20 * TILE, h = 15 * TILE },
spawn = pos(2, 12),
enemies = [
{ kind = "goblin", hp = 20 },
{ kind = "troll", hp = math.floor(80 * 1.5) }, -- call functions inline
],
}
Any expression can be a field value — a function call, arithmetic, a string interpolation, a nested literal, a conditional. That's the whole point: the language drives the construction. Another file then imports it:
import world_01 -- runs the file once, caches the returned table
print(world_01.name)
See the runnable examples/level_data.scua
for a fuller example with inline helpers and schema-validated
records.
#Validate the data with records and contracts
Give the data a shape and let the compiler enforce it. A record with
where refinements checks each value as it crosses into the
type — so a bad value faults during loading, with a
located, introspectable error, not at 3am when a player attacks with a
negative-damage sword:
record Item {
name: string,
damage: int where damage >= 0,
rarity: string where rarity in ["common", "rare", "epic"],
}
fn weapon(name, damage, rarity)
let it: Item = { name = name, damage = damage, rarity = rarity } -- checked here
return it
end
For invariants that span the whole dataset (not one field), use a contract after loading:
contract ValidLevel(lvl)
lvl.waves.len() >= 1 else "a level needs at least one wave"
lvl.spawn != nil else "spawn point required"
end
if not (level matches ValidLevel) then error("bad level data") end
where catches per-field problems at construction;
contracts catch cross-field invariants. Together that's a complete,
code-first validation story with no bespoke validator.
#The persistence win (free serialization)
In Lua, sending a defined level to a server means writing a
serializer (or exporting to JSON and losing the functions). In SCUA a partition is its serialized
form: the GC's compacted bytes are the blob. So the host can build a
level in a partition, persist it with a single
checkpoint (no encoding, no schema kept in sync with a
serializer), and reload it later — even on another machine, at a
different address — with all the structure and shared references intact.
For large, generated worlds this is a real cost saved. Schema changes
are the same evolution
story as save data: add a field with a default and old blobs load
fine; rename one and a migrate hook handles it.
#The sandbox win (loading untrusted data safely)
Loading a mod or user-supplied level is the classic Lua
security nightmare: dofile runs with full ambient
authority, so you must swap _ENV for a whitelist — fragile,
and routinely gotten wrong.
In SCUA there's nothing to strip. Run the data file in a
pure partition (the default — no
capabilities) and it has no I/O authority by
construction: import fs and
import net are simply undefined name there,
and there is no ambient global to reach. The host can even enumerate the
file's imports before running a line, to audit its full reach. This is
the capability model doing the work a
hand-rolled Lua sandbox tries to:
-- a hostile mod file, loaded in a pure partition:
import fs -- compile error: undefined name (fs isn't granted, so it isn't a name)
import net -- compile error: undefined name
-- there is no other way to reach the outside world. Pure is the default.
Procedural data that needs randomness is still safe: grant only the
seeded, deterministic rand capability — never
ambient entropy — so the generation is reproducible.
#How the game reads it
Two paths, depending on size:
- Small data: the game partition does
import world_01directly and reads the table. Simple. - Large data, shared: the host evaluates the data file in its own partition, then hands the game partition the result (a cross-partition message copies what it needs, or the host keeps the data partition alive and reads it through the embedding API). This avoids copying a big level into every reader.
(Either way, you address the loaded table with ordinary paths: level @ "size/w".)
#See also
- Records and gradual types — the shapes data modules validate against.
- Contracts — whole-dataset invariants.
- Modules — the
return-a-value mechanism a data module rides. - Persist and migrate — the free-serialization and schema-evolution story.