SCUA

Manual

Core concepts

SCUA is a dynamic scripting language, so most of it will feel familiar. Four ideas set it apart from the usual scripting language, and they're worth getting straight before the rest of the docs. This page explains each one and points you at the guide page that covers it in full.

#Dynamic by default, gradually typed

You can write SCUA with no type annotations at all. Variables hold whatever you put in them, functions take whatever you pass, and nothing is checked until it runs. That's the default, and it's a complete way to use the language.

fn describe(x)
  return `got: {x}`
end
print(describe(42))
print(describe("a string"))
print(describe([1, 2, 3]))
$ scua describe.scua
got: 42
got: a string
got: [1, 2, 3]

When you want a guarantee, add annotations. The compiler checks them before the program runs and reports mistakes with a line number.

fn area(w: number, h: number) -> number
  return w * h
end
print(`area = {area(3, 4)}`)
$ scua area.scua
area = 12

Pass an argument of the wrong type and you hear about it at compile time, not in production:

fn area(w: number, h: number) -> number
  return w * h
end
print(area(3, "four"))
$ scua area.scua
scua: area.scua:4: type error: argument 2 to 'area' is string, expected number

One thing matters here: annotations are erased. They drive the compile-time check and then they're gone. A program runs the same with them or without them, so adding a type can catch a bug but can never change behavior. Annotate the parts of a program you want to nail down, leave the rest dynamic, and mix the two freely. Records and the full type syntax are covered in Records and gradual types.

#Partitions, and why state is cheap to persist

State in SCUA lives in partitions. A partition is an isolated region of memory with its own data. What makes it useful is the memory layout: a partition's bytes are position-independent. Nothing inside points at a fixed memory address, so the whole partition is just a blob of bytes that means the same thing wherever it lives.

That property is what makes persistence cheap. To save a partition you write its bytes out. To restore it, on the same machine or a different one, you read those bytes back. You don't write serialization code, hand-maintain a schema, or turn objects into JSON and back. The state that a partition holds, a per-player inventory, the state of one match, an NPC's memory, can be parked on disk and resumed later as-is.

You don't need to know how the memory manager achieves this to use it. What you do need to know is the shape it gives your program: put state that should persist or move together into the same partition. Saving, loading, and migrating that state is walked through in Save, load, and migrate game state.

Saving and loading happens from the host: SCUA runs embedded in another program (a game engine, a server, a tool), and that host serializes a partition to bytes and reads them back, with no serialization code on either side. If you're writing the host rather than the scripts, Embed SCUA in a host program shows the C interface end to end.

#The actor model

Partitions are isolated from each other. One partition cannot reach into another's memory, so there is no shared mutable state and no data races to reason about. They coordinate by passing messages.

A partition declares state fields and handlers. You send it a message two ways: tell is fire-and-forget, and ask waits for a reply. Only the partition's own handlers run against its state, one message at a time, so a handler never has to lock anything or worry about another thread mid-update.

partition Counter
  state n = 0

  on Bump(by)
    n = n + by
    print(`counter is now {n}`)
  end
end

let c = Counter()
tell c.Bump(3)
tell c.Bump(4)
$ scua counter.scua
counter is now 3
counter is now 7

Counter() creates an instance. on Bump(by) is a handler. Inside it, n is the partition's own state, and each tell delivers one message that the handler processes start to finish. Messages are copied as they're sent, which is the same isolation that makes a partition safe to persist. The full model, including ask and replies, request timeouts, and passing references between partitions, is in Partitions and the actor model.

#Two kinds of failure

SCUA splits failure into two categories, and keeps them apart on purpose.

The first is expected failure: a lookup that misses, input that doesn't parse, a charge that exceeds a balance. These are values. A function that might fail returns Ok(value) or Error(message), the caller decides what to do, and the postfix ? operator unwraps an Ok or returns the Error to its own caller.

fn charge(balance, amount)
  if amount > balance then return Error("insufficient funds") end
  return Ok(balance - amount)
end

match charge(100, 30)
  Ok(left) -> print(`charged, {left} left`)
  Error(e) -> print(`declined: {e}`)
end
$ scua charge.scua
charged, 70 left

The second is a fault: a bug, like dividing by zero or hitting a case you thought was impossible. Faults aren't ordinary values you pass around. They unwind until something catches them with try ... rescue, which is your chance to log and recover instead of crashing.

fn safe_div(a, b)
  try
    return Ok(a // b)
  rescue err
    return Error(`math error: {err}`)
  end
end

match safe_div(1, 0)
  Ok(x)    -> print(x)
  Error(e) -> print(e)
end
$ scua safe_div.scua
math error: divide by zero

The rule of thumb: use a Result when a caller can reasonably be expected to handle the failure, and let faults represent the cases that mean your code is wrong. Both are covered in Errors and faults.