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. Iterating a table with
for key, val in ... visits the entries in the order they
were inserted. With a single loop variable you get the values.
#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. That's the right default, but for bulk numeric
data — vertex floats, a point cloud, pixels, indices — you
usually want the numbers stored packed and contiguous, like a C
float*. Annotate the element type and you get exactly that:
the array stores its elements unboxed.
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
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
first hop length = 5
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.
{ f64 } also halves the memory of the boxed default while
keeping full precision.)
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