SCUA

News

SCUA 0.33.0 makes records, typed arrays and actors faster

October 5, 2026

SCUA 0.33.0 is a speed release. A loop that builds small records now runs as fast as LuaJIT, functions with typed parameters cost about the same as untyped ones, and typed arrays of every number type run at full speed. Actors use less memory, and a handler can now fail the whole run when a tool's work goes wrong. There is a short guide to upgrading at the end.

#Loops that build small records no longer allocate

Building a small record in a loop and reading it straight back is a common way to write game and simulation code. The optimizing JIT now sees when a record never leaves the loop, keeps its fields in registers and never builds the record at all:

let total = 0
for i in 0:2000000 do
  let p = { x = i, y = i * 2, vx = 1, vy = -1 }
  total = total + p.x * p.vx + p.y * p.vy
end
print(total)
$ scua particles.scua
-1999999000000

This loop took 27 ms on an Apple M4 Max with 0.32 and takes 5 ms now; LuaJIT takes 12 ms. On an Intel i7-10700 running Linux it went from 76 ms to 5 ms, level with LuaJIT. The loop also no longer starts the garbage collector. When your code does need the record, for example to pass it to a function or store it somewhere, SCUA builds it as before.

#Typed parameters cost about the same as untyped ones

A parameter with a record type checks its argument on every call. That check now remembers the last record it accepted, so a call that passes the same kind of record pays for one comparison:

record Npc { x: float, speed: float }

fn step(n: Npc, dt: float)
  return n.x + n.speed * dt
end

Calls like step(n, 0.001) are about 3 times faster without the JIT and 4 times faster with it, about as fast as the same function without the type, and the JIT can now fold them into the loop that calls them. This works for fields of every type, including string, bool, decimal and big, and for records of any width: a record with 20 fields checks up to 100 times faster than before.

#Typed arrays of every number type run at full speed

Loops over { i32 }, { u32 }, { u16 }, { u8 } and { f32 } arrays now run as fast as { f64 } and plain arrays: up to 40 times faster with the JIT and about twice as fast without it. Filling one with push is about 3 times faster.

A number that doesn't fit is reported with the range the array holds:

let level: { u8 } = []
for i in 0:10 do
  push(level, i * 20)
end
print(level)
push(level, 300)
$ scua bytes.scua
[0, 20, 40, 60, 80, 100, 120, 140, 160, 180]
scua: bytes.scua:6: can't add 300 to a typed {u8} array: it holds whole numbers from 0 to 255

#Actors use less memory and stay fast

  • Each actor now decides when to collect garbage from its own memory use. In a program with one large actor and sixty small ones, peak memory went from 2.38 GB to 547 MB.
  • A program can revive thousands of actors, or run thousands that have each collected garbage, without running out of memory early.
  • Actors in a script embedded through the C API cost about a quarter of the memory and time they did, the same as under the scua command.
  • A hot loop inside an actor that calls your functions or built-ins runs at full JIT speed, the same as at the top level.
  • On x86-64, two actors running the same numeric loop went from 240 ms to under 30 ms, because a loop that leaves optimized code gets back into it within a few iterations.

#A handler can fail the run

sys.fail in a partition's handler now marks the whole run as failed, so a tool whose work runs in a partition can report failure through its exit status. The handler's turn ends, the partition keeps its state and handles its next message, and the program exits with the code when it finishes:

import sys

partition Checker
  state seen = 0

  on Check(path)
    if path == "" then sys.fail("no path given") end
    seen = seen + 1
    print(`checked {path}`)
  end
end

let c = Checker()
tell c.Check("a.txt")
tell c.Check("")
tell c.Check("b.txt")
$ scua check.scua; echo "exit $?"
checked a.txt
scua: check.scua:7: partition Checker, message Check: the turn ended with Error(no path given); state kept
checked b.txt
{"error":"no path given"}
exit 1

sys.exit with a non-zero code does the same without the message, and sys.emit writes its result and ends the turn. In a spawned task, the three stop that task. return Error(e) from a handler still refuses one message and leaves the exit status alone.

#Sandboxes exchange only data

Messages to a sandboxed actor and its replies now go through the same check as its starting state, so only data crosses in either direction. Code inside a sandbox can't start actors or bring a saved one back. Each actor gets its capabilities from the code that created it, so it never holds more than its creator.

#Smaller additions

  • Files with a few hundred top-level functions, or a large table of distinct keys, now compile. The limit is 65,535 distinct constants in one function, and reaching it names the line.
  • A value that can't be sent between partitions is named, with where it sits: argument 2 of `Take` (at `.cb`) is a function that captures variables.
  • --max-ops counts creating an actor as one step.
  • Actors can use the cluster module with the run's secret.
  • A table column named print or world can be used by its bare name in filter and with.

#Upgrading to 0.33

  • Inside filter and with, a name you declared yourself is an error beside a column of the same name, even when it is also a built-in name. Write row.year for the column, or rename your function:

    let sales = frame({ year = [2024, 2025, 2025], amount = [10, 20, 5] })
    
    fn year(d) return d end
    
    print(sales.filter(row.year == 2025).rows())   -- 2
  • --max-mem (and an embedder's mem_cap) is one total for the whole program, actors included. A program that creates many actors under a tight cap may need a larger one; --mem-stats shows the total as all-live.

  • In 0.32, a variable that a hot while loop only assigned could read an earlier value after the loop. If you saw a number like that, 0.33 gives the right one.

#What's next

Work on actor memory continues, so programs with many actors take less of it. Your own record types still get operators, so a vector or a unit type you write can use + and < the way the built-in ones do.

The full changelog has everything in 0.33.0.