SCUA has two built-in containers. A table is a string-keyed map, written with braces. An array is an ordered sequence, written with square brackets and indexed from 0. You reach for a table when you have named fields, and an array when you have a list of things.
#Tables
A table maps string keys to values. Write it with
key = value pairs inside braces, read and write fields with
dot notation, and add a new key just by assigning to it:
let player = { name = "Ed", level = 7 }
print(player.name)
player.level = player.level + 1
print(player.level)
player.gold = 100
print(player.gold)
for key, val in player do
print(`{key} = {val}`)
end
Run it:
$ scua tables.scua
Ed
8
100
name = Ed
level = 8
gold = 100
Tables grow freely: player.gold = 100 adds a key that
wasn't there before. With a single loop variable you get the values.
Iterating a small table you have only ever added to visits the entries in insertion order, as above. That's the common case, but it isn't a promise you can lean on: a table with more than sixteen keys, or one you've removed a key from, comes back in an unspecified order. If order matters, keep an array of keys alongside the table.
#Keys that aren't names
A key is a name. Write it bare when it's an identifier, and in quotes when it isn't — which is what you need for HTTP headers, JSON with hyphens or dots, and data exported from somewhere else:
let headers = {
"content-type" = "application/json",
"x-api-key" = key,
accept = "application/json",
}
print(headers["content-type"])
Both forms mean the same thing and compile to the same code, so
there's exactly one way to write any given key: quoting one that's
already a name ({ "accept" = … }) is an error that tells
you to drop the quotes. Two spellings that don't work, in case you
arrive from another language — Lua's { ["k"] = v } and
JSON's { "k": v } — each get an error pointing at the form
SCUA uses.
A reserved word is a key like any other, written in
quotes. JSON Schema has a field called not, and
real data carries end, for and
in. Bare, those would be indistinguishable from the
keyword, so quotes are how you write them — and quoting them is
not the redundant case, because there is no bare spelling to be
redundant with:
let schema = { "not" = { type = "string" } }
print(schema["not"].type)
$ scua keys.scua
string
That one line shows both halves of the rule. not is
reserved, so it takes quotes. type is only
sometimes a keyword — like state and
ask, it stays an ordinary name — so it's written bare, and
quoting it would be the redundant case. Whichever a word is, exactly one
of the two spellings works, and the error tells you which.
A key that isn't known until runtime is still an assignment, because it isn't a literal at all:
let field = `x-{n}`
headers[field] = "computed"
#Removing a key
delete(t, k) removes a key. Assigning nil
does not — it sets the value to nil and leaves
the key in place, so len still counts it and
for … in still yields it:
let cache = {}
cache["hp"] = 30
cache["mp"] = 12
cache["xp"] = 400
cache["mp"] = nil
print(`after nil: len = {len(cache)}, cache["mp"] = {cache["mp"]}`)
print(delete(cache, "mp"))
print(delete(cache, "mp"))
print(`after delete: len = {len(cache)}`)
Run it:
$ scua removing.scua
after nil: len = 3, cache["mp"] = nil
true
false
after delete: len = 2
It also reads as a method: cache.delete("mp") is the
same call. The rule in one sentence: assigning nil sets the
value; delete removes the key. Both make
cache["mp"] read back as nil, and that's the trap — reach
for delete whenever you want the key gone, which is what a
cache, an index or a set almost always wants.
delete returns whether the key was
there, not what it held. If you want the value, read it
first:
let evicted = cache["hp"]
if delete(cache, "hp") then
print(`evicted {evicted}`)
end
Two things it won't do. A record's
fields are fixed, so deleting one is an error rather than a way to punch
a hole in a typed value. And an array isn't a table: use
remove(array, index) there — delete on an
array tells you so.
Don't delete from a table while you're iterating it. Collect the keys first, then delete them:
let entities = {}
entities["e1"] = 10
entities["e2"] = 0
entities["e3"] = 7
let dead = []
for id, hp in entities do
if hp == 0 then push(dead, id) end
end
for id in dead do delete(entities, id) end
print(`{len(entities)} alive`)
$ scua reap.scua
2 alive
Here is the version to avoid, so you recognise it:
for id, hp in entities do
if hp == 0 then delete(entities, id) end -- don't: mutating what you're iterating
end
It won't crash, and in a small test it will look right. But a delete moves entries around inside the table, so the loop can step past one — it mostly works and occasionally misses something, which is the worst kind of bug to find later.
One name to keep apart: cluster.delete in the shared config
table means something different. It writes a tombstone that
has to reach every node in the cluster. delete on a plain
table leaves nothing behind.
#Arrays
An array holds an ordered run of values. It is 0-based: the first
element is arr[0], and an array of length n
has its last element at arr[n-1]. When you want to annotate
one, an array type is written [T] — e.g.
let scores: [int] = [10, 20]. The { T }
spelling is equivalent (and reads naturally for the element-typed numeric buffers
below, e.g. { f32 }).
let scores = [10, 20, 30, 40]
print(scores[0])
print(scores[3])
scores[1] = 99
print(scores)
Run it:
$ scua arrays.scua
10
40
[10, 99, 30, 40]
This is the one to keep in mind if you're coming from Lua, where arrays start at 1. In SCUA they start at 0:
let items = ["sword", "torch", "rope"]
print(`first item is items[0]: {items[0]}`)
print(`last item is items[2]: {items[2]}`)
Run it:
$ scua zero-based.scua
first item is items[0]: sword
last item is items[2]: rope
A three-element array is indexed 0, 1,
2. There is no element at index 3.
#Array builtins
These builtins operate on arrays. len reports the count,
push and pop add and remove at the end,
remove drops by index, contains tests
membership, slice copies a sub-range, and
range builds an array of consecutive ints.
let q = [1, 2, 3]
print(len(q))
push(q, 4)
print(q)
print(pop(q))
print(remove(q, 0))
print(q)
print(contains(q, 3))
print(slice([10, 20, 30, 40], 1, 3))
print(range(0, 5))
Run it:
$ scua array-builtins.scua
3
[1, 2, 3, 4]
4
1
[2, 3]
true
[20, 30]
[0, 1, 2, 3, 4]
push and remove change the array in place.
pop removes the last element and returns it.
remove(q, 0) drops the element at index 0 and shifts the
rest down, returning what it removed. slice(a, start, end)
returns a fresh array of the elements in [start, end), so
slice(..., 1, 3) gives indices 1 and 2.
range(a, b) returns the ints from a up to but
not including b; the one-argument range(n)
counts from 0. When you know how big an array will get,
xs.reserve(n) pre-sizes it in one step so the fill loop
never re-allocates — worth it for big buffers, unnecessary for small
ones.
#Slicing arrays
Indexing with a range, arr[start:end], gives a new array
of that half-open span. Either bound can be left off, and out-of-range
bounds clamp:
let row = [10, 20, 30, 40, 50]
print(row[1:3])
print(row[:2])
print(row[3:])
print(row[:])
Run it:
$ scua slicing.scua
[20, 30]
[10, 20]
[40, 50]
[10, 20, 30, 40, 50]
row[1:3] is indices 1 and 2. row[:2] takes
from the start, row[3:] runs to the end, and
row[:] copies the whole array.
#Iterating
Both containers work with for ... in. Over an array you
can take the value alone or the index and value together; over a table,
the value alone or the key and value together. See Control flow for the loop forms. Arrays of
tables are a common shape:
let party = [
{ name = "Ed", hp = 100 },
{ name = "Ana", hp = 80 },
]
for member in party do
print(`{member.name}: {member.hp} hp`)
end
Run it:
$ scua party.scua
Ed: 100 hp
Ana: 80 hp
For map, filter, reduce, and other functional operations over arrays,
import the built-in list module and pass it a function; see
Functions and closures for the
function-passing style.
#Element-typed numeric buffers
A plain array is boxed: each slot is a tagged value that can
hold anything. For bulk numeric data — vertex floats, a
point cloud, pixels, indices — you can instead ask for the numbers
stored packed and contiguous, like a C float*. Annotate the
element type and the array stores its elements unboxed,
in exactly that layout.
Two things this buys, and one it doesn't:
- A layout a host can read directly. This is the main
reason to reach for one — a script fills a
{ f32 }vertex array and the engine hands its packed bytes straight to the GPU, with no copy and no conversion. - Smaller memory, for the narrow kinds.
{ f32 }is 4 bytes an element and{ u8 }is 1, against 8 for a plain array of numbers. - Not speed. Arithmetic over a
{ f64 }measures the same as over a plain array of floats, on either execution tier. A plain array of numbers is already stored unboxed (SCUA notices that every element is a number and packs it for you), so{ f64 }in particular saves nothing over leaving the annotation off — its value is that the layout is declared and guaranteed rather than inferred. Reach for these buffers for the host boundary and for the narrow kinds, not for a speed-up.
The element type can be a scalar — f32
(single-precision) or f64 (a full, exact double),
i32, u8, u16, u32 —
or a packed vector — vec2, vec3,
vec4:
let heights: { f32 } = []
heights.push(1.5)
heights.push(2.5)
heights.push(3.0)
let sum = 0.0
for h in heights do sum = sum + h end
print(`{heights.len()} heights, sum {sum}`)
$ scua heights.scua
3 heights, sum 7.0
It's still an array — index, push, pop,
slice, and for ... in all work exactly as above. The only
differences are the packed storage and that a value which doesn't fit
the element type is rejected (see below).
A { vec2 } (or vec3/vec4)
buffer packs each point as adjacent floats, and reads each element back
as a real vector — with .x/.y, swizzles, and
the usual vector math (see Math):
let path: { vec2 } = []
path.push(vec2(0, 0))
path.push(vec2(3, 4))
path.push(vec2(6, 0))
print(`second waypoint = {path[1]}, its x = {path[1].x}`)
print(`first hop length = {(path[1] - path[0]).length()}`)
$ scua path.scua
second waypoint = vec2(3, 4), its x = 3.0
first hop length = 5.0
The declared element type holds however the array was
built. let v: { f32 } = fill(n, 0.0), a
{ f32 } filled from range, one returned by a
function declared -> { f32 }, and a record field
declared { f32 } all end up as real packed buffers — the
annotation is applied where the value arrives, not only to a literal,
and an array whose elements don't fit the declared type is refused there
rather than quietly kept as a plain array.
A value that doesn't fit the element type is a located fault,
not a silent wrap. Storing 300 into a
{ u8 } (whose values are 0–255),
or a string into a { f32 }, faults at the store — the same
way integer overflow does, so a buffer
never silently holds a truncated value. Storing a vec2
works into a { vec2 } but not a { vec3 }.
(f32 values are single-precision, so a number with more
precision than a 32-bit float can hold is rounded, like any float
narrowing — use { f64 } when you need the number stored
exactly, at 8 bytes per element instead of 4.)
These buffers are how a host engine consumes script-built data with
no copying: a script fills a { f32 } vertex array and the
host hands its packed bytes straight to the GPU. See Drive scripts from an
engine for that path.
#Paths into nested data
When data nests a few levels deep, reaching in field by field gets
tedious and breaks the moment a level is missing. A
path addresses a location inside nested tables with a
single /-separated string. The get and
set builtins read and write through one:
let world = {
player = { stats = { gold = 100 } },
}
print(get(world, "player/stats/gold"))
set(world, "player/stats/hp", 75)
print(get(world, "player/stats/hp"))
print(get(world, "player/inventory/sword"))
let gold_path = path"player/stats/gold"
set(world, gold_path, 250)
print(get(world, gold_path))
Run it:
$ scua paths.scua
100
75
nil
250
Three things to notice. get reaching a missing location
returns nil rather than failing, so
get(world, "player/inventory/sword") is nil
even though there's no inventory at all. set
creates the intermediate tables it needs along the way, so writing to
player/stats/hp works even though hp wasn't
there before. And path"..." makes a path into a first-class
value you can store in a variable and reuse, like gold_path
above.
There's also an operator form, value @ "a/b/c", that
reads and writes the same locations inline, with ?? to
supply a default when a location is missing:
let player = {
profile = { name = "Ed" },
stats = { gold = 100 },
}
print(`name: {player @ "profile/name"}`)
player @ "stats/gold" = player @ "stats/gold" + 50
print(`gold: {player @ "stats/gold"}`)
print(`guild: {player @ "profile/guild" ?? "none"}`)
Run it:
$ scua path-operator.scua
name: Ed
gold: 150
guild: none