SCUA

Manual

Data and level definition

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_01 directly 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