SCUA

Manual

Math: vectors, matrices, quaternions

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:

  • mat2 is a 2x2 linear transform: rotation and scale in 2D, but no translation (a 2x2 matrix can't translate).
  • mat3 is a 2D affine transform: it rotates, scales, and translates a vec2.
  • mat4 is a 3D affine transform: it does the same for a vec3.
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 ab
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 use s.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 , 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.