SCUA

News

SCUA 0.34.0 makes an idle actor cost 2.3 KB

October 6, 2026

SCUA 0.34.0 changes how partitions, SCUA's actors, are built. Every partition now shares the program's code, constants and standard library, so an idle one costs about 2.3 KB of memory and is created in half a microsecond on Apple silicon. A message costs the same with 100,000 idle partitions beside it as with none, so a server can keep one per player or per session and leave most of them asleep. The language around partitions is tighter as well: a partition reaches only what it is given, const freezes its value, libraries can keep state for each partition, and live values let you retune a program while it runs. An idle partition's save is under 2 KB, and a revived partition runs the current version of your program. Some programs need edits, and the compiler names a fix for each one; there is a short guide to upgrading at the end.

#An idle partition costs 2.3 KB

These figures compare release builds of 0.33 and 0.34 on an Apple M4 Max running macOS and an Intel i7-10700 running Linux:

Apple M4 Max Intel i7-10700
Memory for an idle partition 92.5 KB → 2.3 KB 77 KB → 2.3 KB
Creating a partition 51 µs → 0.5 µs 107 µs → 1.5 µs
An ask beside 100,000 idle partitions 45 ms → 0.5 µs 81 ms → 1.1 µs
A partition that does a little work often 1.5 MB → 76 KB 1.4 MB → 69 KB

At 2.3 KB each, 100,000 idle partitions fit in about 230 MB. In 0.33 the same number needed over 9 GB on the Mac.

A partition used to start with its own copy of the program. Now each function is compiled once, by the first partition that runs it often enough, and every partition runs that copy with its own caches. In a program with 150 functions, a partition that calls all of them holds about 34 KB instead of 450 KB. A partition that reads a 1 MB constant holds about 5 KB of its own instead of 950 KB. Once a partition has handled its first message it holds about 12 KB, since it now has a mailbox and caches for the functions it called.

The scheduler visits only the partitions that have something to do, which is why an ask costs the same beside 100,000 idle partitions as beside none. Dropping a partition leaves nothing behind for later work to pay for: a program that opened and closed 100,000 partitions peaked at about 12 MB, where it used to pass 200 MB, and reviving one partition 100,000 times keeps memory flat too.

Code inside a partition runs as fast as before. Some loops got faster: for … in over an array now runs as compiled code, so a handler that walks an array of records is about 7 times faster on Apple silicon and 8 times faster on x86-64. On x86-64, a compiled loop that reads a top-level const used to drop to a slow path after a garbage collection; it now stays fast, about 7 times faster per element than before.

#A partition reaches only what it is given

A partition can use the program's functions, its constants, the standard library and the modules it imports. It can't reach the main script's variables. A top-level let belongs to the main script, and a handler that reads one, directly or through a function it calls, is a compile error that names the variable, the path to it and a fix:

let limit = 3

fn allowed(n) return n <= limit end

partition Gate
  on Check(n) print(allowed(n)) end
end

tell Gate().Check(2)
$ scua gate.scua
scua: gate.scua:6: the `Check` handler of `Gate` calls `allowed`, which reads `limit`, a variable of the main script (`let limit`, line 1). A partition can't reach the main script's variables. `limit` never changes after line 1, so make it a constant, `const limit = …`, or pass it in: `partition Gate(limit)` with `state limit = limit`

Changing let to const on line 1 makes the program print true. Between this rule and frozen constants, nothing a partition reads from the program can change under it, which is why every partition can share one copy.

To give a partition values when you create it, name them after the partition and keep what you need in state. A handler named on Start() runs once when the partition is created, before any message:

partition Shop(catalog, owner)
  state items = catalog
  state owner = owner
  state sold = 0

  on Start()
    print(`{owner} opens the shop`)
  end

  on Buy(item)
    sold = sold + 1
    print(`{owner} sold a {item} for {items[item]} (sale #{sold})`)
  end
end

const PRICES = { sword = 50, potion = 10 }
let s = Shop(PRICES, "Mara")
tell s.Buy("sword")
tell s.Buy("potion")
$ scua shop.scua
Mara opens the shop
Mara sold a sword for 50 (sale #1)
Mara sold a potion for 10 (sale #2)

#const freezes its value

A const now fixes the value as well as the name, at every depth. A write the compiler can see is a compile error, and any other write stops the program; both name the constant:

const CONFIG = { speed = 4, tags = ["fast"] }
CONFIG.tags.push("slow")
$ scua config.scua
scua: config.scua:2: `CONFIG` is a constant: `const` in SCUA freezes the value, not just the name; use `let` for a list you add to

To build a constant step by step, fill it in a let and freeze it with the new freeze function, which returns a frozen copy:

let by_name = {}
for item in [{ name = "sword", damage = 5 }, { name = "bow", damage = 3 }] do
  by_name[item.name] = item
end
const BY_NAME = freeze(by_name)
print(BY_NAME.sword.damage)
$ scua weapons.scua
5

Everything a module exports is frozen once its top level has run, so every file that imports a library sees the tables it hands out as the library wrote them.

#Libraries keep state for each partition

A module can declare state at its top level. Each partition that uses the module gets its own copy, made the first time it calls in, and the main script has one too. Here is a small library that tracks running tweens:

-- tween.scua
import ids

state active = {}

fn start(obj, to, secs)
  let id = ids.next()
  active[id] = { obj = obj, to = to, left = secs }
  return id
end

fn running() return len(active) end

return { start = start, running = running }
-- game.scua
import tween

partition Zone
  on Spawn(what)
    tween.start(what, 10, 2)
    print(`{what}: {tween.running()} running here`)
  end
end

tween.start("title", 1, 1)
tell Zone().Spawn("goblin")
tell Zone().Spawn("bat")
print(`main script: {tween.running()} running`)
$ scua game.scua
main script: 1 running
goblin: 1 running here
bat: 1 running here

A partition's module state is saved with it when you snapshot it. ids.next(), used above, is new too: it returns a 64-bit integer id from a counter each partition starts at a random point, so ids from different partitions and processes don't collide in practice and no partition has to ask another for one.

#Retune a running program with live values

A live value is a table of settings that the main script, or a program embedding SCUA, can replace while every partition keeps running. It is how you change a goblin's hit points or a feature switch in a game or a server without a restart:

live TUNING = { goblin_hp = 25, speed = 1.0 }

partition Spawner
  on Report() print(`new goblins: {TUNING.goblin_hp} hp, speed {TUNING.speed}`) end
end

let s = Spawner()
tell s.Report()
wait(10ms)
live.publish(TUNING, { goblin_hp = 50 })?
tell s.Report()
wait(10ms)

let from_file = { goblin_hp = "lots" }
match live.publish(TUNING, from_file)
  Ok(v) -> print(`published version {v}`)
  Error(e) -> print(`refused ({e.kind}): {e.message}`)
end
$ scua tuning.scua
new goblins: 25 hp, speed 1.0
new goblins: 50 hp, speed 1.0
refused (invalid): `TUNING.goblin_hp` holds an int, and the publish gave it a string

The keys and their types are fixed where the value is declared, so a publish that doesn't fit changes nothing and comes back as an Error you can handle. Several values published in one call are read together, and a handler reads one version from its start until it next waits, so a loop over a thousand entities uses one set of numbers throughout. A host publishes from JSON with scua_live_publish.

#References you can compare and hand out

Two references to the same partition are now equal and work as the same table key, so a room can keep its members in a table keyed by reference. actors.me() is a partition's own address, as a send-only reference: whoever you hand it to can send you messages and nothing else.

import actors

partition Room
  state members = {}
  on Join(m)
    members[m] = true
    print(`joined, {len(members)} in the room`)
  end
  on Leave(m)
    delete(members, m)
    print(`left, {len(members)} in the room`)
  end
end

partition Player
  on Enter(room)
    tell room.Join(actors.me())
    tell room.Join(actors.me())    -- the same player again: still one member
    tell room.Leave(actors.me())
  end
end

tell Player().Enter(Room())
$ scua room.scua
joined, 1 in the room
joined, 1 in the room
left, 0 in the room

A reference prints as its locator, such as <actor 3f1c09a2b7d45e60>, which is the same in every process and after every revive, so you can follow one partition through your logs. In a handler, actors.sender() says who sent the message being handled. A message to a partition that isn't running is counted and reported on stderr with its sender and target, and an ask to one gets Error({ kind = "gone" }) straight away.

#Saves revive into the current program

A snapshot now holds the partition's own data and nothing else of your program. An idle one is under 2 KB:

import actors

partition Session
  state visits = 0
  on Visit() visits = visits + 1 end
end

let blob = actors.snapshot(Session())?
print(`an idle session saves in {len(blob)} bytes`)
$ scua idle.scua
an idle session saves in 1984 bytes

The same session on 0.33 saved 71,832 bytes. Snapshotting a 16-record session takes 0.005 ms and reviving it 0.05 ms.

Because the save holds no code, a revived partition runs the program that revived it. A function added since the save is there, and a constant you changed has its new value, including in a table the partition stored from it. This shop is saved by one version of a program:

import actors
import fs

const ITEMS = { sword = { id = "sword", price = 12 }, shield = { id = "shield", price = 5 } }

partition Shop
  state items = ITEMS
  on Show() print(`sword {items.sword.price}, shield {items.shield.price}`) end
end

let s = Shop()
tell s.Show()
wait(0ms)
fs.write("shop.blob", actors.snapshot(s)?)?

The next version raises the sword to 15 and revives the saved shop in place of the last three lines:

let back = actors.revive(fs.read("shop.blob")?)?
tell back.Show()
$ scua --allow-fs=. shop_v1.scua
sword 12, shield 5
$ scua --allow-fs=. shop_v2.scua
sword 15, shield 5

A balance patch reaches every saved shop this way. A list element follows the program when it has an id field, and a value the partition took out and kept as its own, such as a single price, stays as it was. A save also revives after you add a field with a default to a record it holds.

On a server where saves pass through storage you don't fully control, --blob-keys=FILE signs every snapshot with your deployment's key and revives only those that verify. Here the shop is saved and revived under one key, then revived under another:

$ scua --allow-fs=. --blob-keys=../keys/deploy.keys shop_v1.scua
sword 12, shield 5
$ scua --allow-fs=. --blob-keys=../keys/deploy.keys shop_v2.scua
sword 15, shield 5
$ scua --allow-fs=. --blob-keys=../keys/other.keys shop_v2.scua
{"error":{"kind":"integrity","message":"this blob does not verify against this deployment's keys: it was changed after it was written, or signed with a key this deployment does not hold"}}

A key file can list several keys, so you can rotate keys without stranding old saves, and the run refuses to start if your script could read the file. Only one copy of a partition runs at a time: reviving a save whose partition is already running is refused, and actors.revive(blob, { fork = true }) makes a separate copy when you run with --allow-fork. A store that hands out a generation number when it claims a session can pass it to the revive, so a process still holding an old copy can't overwrite the new one.

#Smaller additions

  • An ask inside a map_all or wait_all arm sends every request at once and collects the answers in order. A partition can have up to 64 asks waiting.
  • actors.stats() reports how many partitions are running, how many are queued, dead letters, and revives refused by reason.
  • scua blob-info shows a save's identity, whether it was signed, and what it is made of.
  • --mem-stats reports the longest garbage collection pause.
  • Reserved words can be used as field names after a dot and as table keys.
  • SCUA_ACTOR_ORDER=shuffle:SEED runs partitions in a seeded random order, to find code that depends on an order the runtime doesn't promise.

#Upgrading to 0.34

Most programs that use partitions need a few edits. The compiler reports each language change below with an error that names a fix that compiles, and it reports every module let and renamed colour in every file your program imports in one compile, so a library moves over in one pass.

  • A partition can't reach the main script's variables. Make the value a const, pass it in as a constructor argument, or keep it in the partition's state. A module's top level can't hold a let either: use const, or state for a value each partition keeps for itself.

  • const freezes its value. const xs = [] followed by xs.push(1) is an error now; use let, or build the value in a let and write const XS = freeze(xs). Everything a module exports is frozen too. chart.palette[0] = "green" in a file that imports chart gives a copy to change:

    scua: main.scua:2: `chart.palette` is an array that the `chart` module exports, and it is frozen: every file that imports `chart` shares it, so it can't be changed from here. Copy it and change the copy: `let my_palette = chart.palette[0:]`
  • actors.freeze is now actors.snapshot, and freeze(value) freezes a value.

  • The named colours moved into a module: import color, then color.red(). color_hex, rgb, srgb and hsv are unchanged.

    scua: old.scua:1: `color_red` was renamed: write `color.red`, and add `import color` at the top of this file
  • on Start is reserved for the handler that runs when a partition is created. A handler that used Start as an ordinary message needs another name.

  • Every error the runtime gives an actor is Error({ kind, message }). A handler that faults while answering an ask now answers Error({ kind = "fault", message = … }), so read e.message where you used the error as text.

  • Saves from 0.33 and earlier are refused: actors.revive returns Error({ kind = "old_format" }), whose message reads "this actor was saved by an older version of SCUA (actor format 7), which this version can't load (it reads actor format 8)". Revive one with the version that wrote it and export what you need as data.

  • Embedding: partitions are named by scua_actor_id instead of a bare uint32_t, and SCUA_ABI_VERSION is 2. Rebuild your host against the new header. A signed session save loads with the new scua_load_keyed.

Rarer changes, such as std becoming a reserved word and forking a revive needing --allow-fork, are in the changelog.

#What's next

The next targets for partitions are the memory one holds once it has handled a message, about 12 KB today, and the 1.5 µs it takes to create one on x86-64.

The full changelog has everything in 0.34.0.