SCUA has vectors, quaternions, and matrices built into the language, with operators that do the obvious thing. They are immutable value types, so an operation returns a new value rather than mutating its inputs. This page is the practical tour for game code; the scalar functions (trig, interpolation, and so on) live in the math module.
One thing to know up front about printing: a float that is a whole
number prints without a decimal point. So a length of 5.0
shows as 5, and a vector reads as
vec3(0.6, 0, 0.8). The values are still floats.
#Vectors
vec2, vec3, and vec4 hold two,
three, and four float components. Construct them by calling the
constructor, and read components with .x, .y,
.z, .w.
let pos = vec3(0, 1, 0)
let target = vec3(3, 1, 4)
let toward = target - pos
print(`offset = {toward}`)
print(`scaled = {toward * 0.5}`)
print(`distance = {toward.length()}`)
print(`direction = {toward.normalize()}`)
$ scua vectors.scua
offset = vec3(3, 0, 4)
scaled = vec3(1.5, 0, 2)
distance = 5
direction = vec3(0.6, 0, 0.8)
+ and - are componentwise. *
with a number scales every component. The rest are operations you call
on the vector — the receiver names the operation:
| Operation | Result |
|---|---|
v.length() |
The magnitude of v. |
v.normalize() |
v scaled to unit length. |
a.dot(b) |
The dot product (a number). |
a.cross(b) |
The cross product of two vec3s (a
vec3). |
let a = vec3(1, 0, 0)
let b = vec3(0, 1, 0)
print(`dot = {a.dot(b)}`) -- 0 (perpendicular)
print(`cross = {a.cross(b)}`) -- the third axis
(The older bare forms — length(v),
dot(a, b) — still work, so existing code keeps running, but
the receiver form is the one to learn.)
$ scua vecops.scua
dot = 0
cross = vec3(0, 0, 1)
#Swizzles
You can read several components at once, in any order, GLSL-style. The number of letters decides the result type.
let v = vec3(1, 2, 3)
print(`v.x = {v.x}`) -- 1
print(`v.xz = {v.xz}`) -- vec2(1, 3) (drop y)
print(`v.zyx = {v.zyx}`) -- vec3(3, 2, 1) (reversed)
print(`v.xxx = {v.xxx}`) -- vec3(1, 1, 1) (broadcast)
$ scua swizzle.scua
v.x = 1
v.xz = vec2(1, 3)
v.zyx = vec3(3, 2, 1)
v.xxx = vec3(1, 1, 1)
A common use is flattening a 3D position to the ground plane:
target.xz keeps x and z and drops the height.
#Quaternions
A quaternion represents a rotation. The two ways you will usually make one are the identity (no rotation) and an axis plus an angle in radians.
| Constructor | Result |
|---|---|
quat.id() |
The identity quaternion. |
quat.axis_angle(axis, angle) |
A rotation of angle radians about
axis. |
quat(x, y, z, w) |
A quaternion from raw components. |
The constructors read under the type as a namespace —
quat.axis_angle(…), mat4.translate(…) (the
older flat names quat_axis_angle,
mat4_translate still work). q * v rotates a
vec3. q1 * q2 composes two rotations into
one.
import math
let forward = vec3(1, 0, 0)
let yaw90 = quat.axis_angle(vec3(0, 1, 0), math.half_pi) -- 90° about Y
print(`yaw90 * forward = {yaw90 * forward}`)
let yaw180 = yaw90 * yaw90 -- compose: 180° about Y
print(`yaw180 * forward = {yaw180 * forward}`)
print(`identity = {quat.id() * forward}`)
$ scua quat.scua
yaw90 * forward = vec3(0.000000059604645, 0, -0.99999994)
yaw180 * forward = vec3(-0.99999976, 0, 0)
identity = vec3(1, 0, 0)
Those near-zeros are ordinary floating-point rounding: a 90° yaw of
(1, 0, 0) is mathematically (0, 0, -1), and
the result is that to several digits. If you need exact comparisons,
compare with a tolerance (math.approx_eq) rather than
==.
#Matrices
Matrices come in three sizes, each with the same four builders:
mat2is a 2x2 linear transform: rotation and scale in 2D, but no translation (a 2x2 matrix can't translate).mat3is a 2D affine transform: it rotates, scales, and translates avec2.mat4is a 3D affine transform: it does the same for avec3.
| Builder | mat2 | mat3 | mat4 |
|---|---|---|---|
| identity | mat2.id() |
mat3.id() |
mat4.id() |
| translate | — | mat3.translate(vec2) |
mat4.translate(vec3) |
| scale | mat2.scale(vec2) |
mat3.scale(vec2) |
mat4.scale(vec3) |
| rotate | mat2.rotate(angle) |
mat3.rotate(angle) |
mat4.rotate(quat) |
The 2D rotations take an angle in radians. The 3D rotation takes a quaternion.
m * v applies a matrix to a vector. m * m
composes two matrices. Matrices are column-major, which means
composition reads right to left: in A * B, B
is applied first. So a model transform built as translate, then rotate,
then scale is written in that order and applies scale first.
import math
-- scale, then rotate, then translate — read right to left
let model = mat4.translate(vec3(10, 0, 0))
* mat4.rotate(quat.axis_angle(vec3(0, 1, 0), math.half_pi))
* mat4.scale(vec3(2, 2, 2))
print(`origin -> {model * vec3(0, 0, 0)}`) -- just the translation
print(`x-axis -> {model * vec3(1, 0, 0)}`) -- scaled, rotated, then translated
$ scua model.scua
origin -> vec3(10, 0, 0)
x-axis -> vec3(10, 0, -1.9999999)
The origin lands at the translation, as expected. The x-axis point is
scaled to length 2, yawed 90° so it points along -z, then translated:
(10, 0, -2) up to rounding.
The 2D story is the same with mat3 and
vec2:
import math
let t = mat3.translate(vec2(100, 50))
* mat3.rotate(math.half_pi)
* mat3.scale(vec2(2, 2))
print(`origin -> {t * vec2(0, 0)}`) -- vec2(100, 50)
print(`x-axis -> {t * vec2(1, 0)}`) -- scaled, rotated, translated
$ scua model2d.scua
origin -> vec2(100, 50)
x-axis -> vec2(100, 52)
mat2 is the linear-only case: a pure rotation or scale
of a direction, with no translation.
import math
let spin = mat2.rotate(math.half_pi)
print(`spin * (1,0) -> {spin * vec2(1, 0)}`) -- ~vec2(0, 1)
$ scua mat2.scua
spin * (1,0) -> vec2(-0.00000004371139, 1)
#Colors
A color is a fourth built-in value type, shaped like a
vec4 (four float components,
r/g/b/a), but it
means something specific: an RGBA color. Like the vectors it is
immutable, compares by value, and supports the
.r/.g/.b/.a
swizzles.
The one thing worth understanding is where the color
lives. A color stores its components in
linear space — the space where adding and blending colors is
mathematically correct. The colors you usually type — a
#ff8800 hex code, a value from a color picker — are in
sRGB space, a perceptual encoding. SCUA keeps these honest:
sRGB and hex are decoded to linear when you build the color, and encoded
back only when you ask for output. There is no global "gamma mode" to
forget to set — the space is decided by which constructor you call.
#Building colors
let red = rgb(1, 0, 0) -- LINEAR components (0..1; may exceed 1 for HDR)
let accent = srgb(1, 0.53, 0) -- sRGB components (what a picker shows), decoded to linear
let bg = #1e1e2e -- an sRGB hex literal, decoded to linear
let wheel = hsv(120, 1, 1) -- HSV: hue in degrees, sat/val 0..1 → green
| Constructor | Input space | Notes |
|---|---|---|
rgb(r, g, b) / rgba(r, g, b, a) |
linear | the raw form; values may exceed 1 (HDR overbright) |
srgb(r, g, b) / srgba(r, g, b, a) |
sRGB | decoded to linear; alpha stays linear |
color_hex("#rrggbb") |
sRGB | also accepts #rrggbbaa, #rgb,
#rgba, and a missing # |
hsv(h, s, v) / hsva(h, s, v, a) |
sRGB wheel | hue in degrees, saturation/value 0..1 |
A hex literal is the ergonomic form of the same
thing: #rrggbb, #rrggbbaa, or the shorthands
#rgb / #rgba (each nibble doubled, so
#f80 is #ff8800). It is decoded at compile
time, so #ff8800 and color_hex("#ff8800")
produce the exact same value.
There are also about 148 CSS named colors (plus
color_transparent()), each a constructor named
color_<name>():
let sky = color_cornflowerblue()
let none = color_transparent() -- the only named color with alpha 0
print(color_red() == #ff0000) -- true: colors compare by value
#Working with colors
Operations are called on the color — the receiver
names the operation, so c.to_hex(),
c.lighten(amt), and so on:
print(accent.to_hex()) -- "#ff8700": encode back to an sRGB hex string
print(#000000.lerp(#ffffff, 0.5).to_hex()) -- "#bcbcbc"
That last line is the point of linear storage. Halfway between black
and white, blended correctly, is a light grey
(#bcbcbc) — not the naive midpoint #808080 you
would get from averaging the sRGB codes. Because every color is already
linear, lerp and ordinary arithmetic just do the right
thing.
| Operation | Result |
|---|---|
a.lerp(b, t) |
blend two colors in linear space (t is not
clamped) |
c.lighten(amt) / c.darken(amt) |
toward white / black by amt (0..1); alpha
unchanged |
c.with_alpha(a) |
a copy with the alpha replaced |
c.inverted() |
1 - rgb, alpha unchanged |
c.luminance() → float |
perceived brightness (Rec. 709, on linear rgb) |
c.to_hex() → string |
sRGB hex: #rrggbb when opaque, else
#rrggbbaa |
c.to_srgb() → vec4 |
the sRGB-encoded components, for a shader or UI that wants them |
(The older bare forms — to_hex(c),
lerp(a, b, t) — still work, but the receiver form is the
documented style.)
Colors are values, so the vector operators carry over:
a + b and c * 0.5 work componentwise (in
linear space), and you can swizzle (warm.rgb). But a
color is its own type — it never silently
mixes with a vec4. red + vec4(1, 1, 1, 1) is
an error, not a quietly-wrong color.
A couple of honest edges to keep in mind. Components are
f32, so a color can go overbright (values above 1,
for HDR/bloom) — that's a feature, but it means to_hex
clamps, since an 8-bit hex code has no room above ff. And
lerp blends in linear space, which is correct but not
always the most perceptually even path through a hue; a
perceptual blend (through a space like OKLCH) may be added later.
See examples/colors.scua
for a runnable tour.
#Geometry shapes
SCUA has six built-in shape types for the geometry you reach for in games — bounds, hit tests, ray casts:
| Shape | Constructor | Holds |
|---|---|---|
rect |
rect(x, y, w, h) |
a 2D rectangle |
aabox |
aabox(min, max) |
a 3D axis-aligned box between two vec3 corners |
sphere |
sphere(center, radius) |
a vec3 centre and a radius |
capsule |
capsule(a, b, radius) |
a swept sphere along the segment a→b |
ray |
ray(origin, dir) |
a vec3 origin and direction |
plane |
plane(normal, d) |
the points p where dot(normal, p) = d |
Like vectors and colors, these are immutable value types. What's new
is how you call their operations: on the shape itself.
You write s.volume(), not volume(s). The
receiver names the operation, so the same word means the right thing for
each shape — area, center,
contains and friends never clash across types, and an
autocomplete list follows from the shape.
let s = sphere(vec3(0, 0, 0), 2)
let box = aabox(vec3(0, 0, 0), vec3(2, 4, 6))
print(`{s.volume()}`) -- 33.51…
print(`{s.contains(vec3(1, 0, 0))}`) -- true
print(`{box.center()}`) -- vec3(1, 2, 3)
print(`{box.closest_point(vec3(5, 5, 5))}`) -- vec3(2, 4, 5)
The operations each shape understands:
| Operation | Shapes | Result |
|---|---|---|
s.area() |
rect, sphere (surface), aabox (surface) | float |
s.volume() |
aabox, sphere, capsule | float |
s.center() |
rect (→ vec2), aabox / sphere / capsule (→ vec3) | the centroid |
s.size() |
rect (→ vec2), aabox (→ vec3) | the extent |
s.radius() |
sphere, capsule | float |
s.length() |
capsule | the segment length (the same length as a vector's) |
s.contains(p) |
rect (vec2), aabox / sphere / capsule (vec3) | bool — is the point inside? |
s.closest_point(p) |
aabox, sphere, capsule, plane | the nearest vec3 on/in the shape |
plane.normal() / plane.distance(p) |
plane | unit normal / signed distance |
sphere.normal(p) |
sphere | outward unit normal at a surface point |
r.origin() / r.dir() /
r.point_at(t) |
ray | the origin / direction / point origin + t·dir |
intersect(a, b) answers whether two shapes overlap or
hit, and it resolves on the pair — the supported combinations
are aabox×aabox, sphere×sphere,
rect×rect, ray×sphere, ray×plane,
and ray×aabox (either order):
let r = ray(vec3(-5, 0, 0), vec3(1, 0, 0))
print(`{intersect(r, sphere(vec3(0, 0, 0), 2))}`) -- true
Operators carry over where they read naturally:
shape + vec (and vec + shape) translates a
shape, and shape - vec shifts it the other way. A ray's
direction and a shape's radius are left alone.
let moved = aabox(vec3(0, 0, 0), vec3(2, 2, 2)) + vec3(10, 0, 0)
print(`{moved.center()}`) -- vec3(11, 1, 1)
The receiver form is the one to learn. The older bare-function form (
volume(s),length(v)) still works so existing code keeps running, but new code and the docs uses.volume().
See examples/geometry.scua
for a runnable tour.
#Scalar helpers
The vector, matrix, and quaternion types above are built into the
language. The plain scalar functions — rounding, trig, interpolation,
angle work — live in the math module:
import math, then call them with the math.
prefix (math.lerp(a, b, t)). They take and return ordinary
numbers.
| Category | Function | Notes |
|---|---|---|
| Rounding & shaping | math.floor(x) / math.ceil(x) /
math.round(x) → int |
toward -inf / +inf / nearest (also top-level builtins) |
math.trunc(x) / math.frac(x) → float |
integer part toward zero / fractional part | |
math.sign(x) → number |
-1, 0, or 1 (subtype preserved) | |
math.sqrt(x) / math.cbrt(x) → float |
square / cube root | |
math.round_to(x, step) → float |
nearest multiple of step |
|
math.fmod(x, y) → float |
floating-point remainder of x / y | |
math.copysign(x, s) / math.hypot(x, y) →
float |
magnitude of x with sign of s / sqrt(x² + y²) |
|
| Exponential & log | math.exp(x) / math.log(x) /
math.log2(x) / math.log10(x) → float |
eˣ, natural, base-2, base-10 |
math.pow(base, exp) → float |
base raised to exp |
|
| Trig | math.sin/cos/tan(x) → float |
radians |
math.asin/acos/atan(x) → float |
inverse, radians | |
math.atan2(y, x) → float |
angle of the vector (x, y) |
|
math.sinh/cosh/tanh(x) → float |
hyperbolic | |
| Interpolation & curves | math.lerp(a, b, t) → float |
linear: a + (b - a) * t |
math.inverse_lerp(a, b, v) → float |
the t for which lerp(a, b, t) == v |
|
math.remap(v, in_lo, in_hi, out_lo, out_hi) →
float |
map v from one range to another |
|
math.smoothstep(edge0, edge1, x) /
math.smootherstep(...) → float |
smooth Hermite interpolation, clamped to [0, 1] | |
math.move_toward(cur, target, max_delta) → float |
step toward target by at most
max_delta |
|
| Angles | math.radians(deg) / math.degrees(rad) →
float |
convert between degrees and radians |
math.wrap_angle(a) → float |
normalize to (-π, π] | |
math.lerp_angle(a, b, t) → float |
interpolate along the shortest path | |
| Predicates | math.is_nan(x) / math.is_inf(x) /
math.is_finite(x) → bool |
classify a float |
math.approx_eq(a, b, eps?) → bool |
whether ` | |
| Integer | math.gcd(a, b) / math.lcm(a, b) → int |
greatest common divisor / least common multiple |
The module also exposes constants as fields: math.pi,
math.tau, math.half_pi, math.e,
math.epsilon, math.inf, and
math.nan.