SCUA

Manual

Collections: tables and arrays

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 0255), 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