Skip to content
Saule
Docs

Math

Numeric utilities. integer and float are distinct types in Saule and nothing promotes between them silently, so Math is written to respect that rather than to paper over it.

Most of it is generic over numeric flavour, written <N> below, where N is integer or float and nothing else. The flavour of the answer follows from what the function does:

  • the ones that pick one of their arguments — abs, min, max, clamp, fmod — answer in the flavour they were given;
  • the ones that compute a real number — sqrt, pow, log, exp and the whole trigonometry family — always answer float;
  • the rounding functions always answer integer.
-- integer in, integer out: no cast either way.
local hp: integer = Math.clamp(137, 0, 100)
println(hp) -- 100
-- A real number is always a float, even from integer arguments.
local root: float = Math.sqrt(9)
println(root) -- 3.0

Flavour still cannot be mixed inside one call. Math.max(1, 2.5) is a type error for the same reason 1 + 2.5 is — cast one side with as float first.

Name Type Value
Math.pi float π
Math.e float Euler’s number
Math.huge float positive infinity
Math.maxinteger integer the largest integer, 2⁶³−1
Math.mininteger integer the smallest integer, −2⁶³
println(Math.pi) -- 3.141592653589793
println(Math.maxinteger) -- 9223372036854775807
-- `huge` is what a comparison against "no limit yet" starts from.
local best: float = Math.huge
for score in {4.5, 2.0, 9.0} do
if score < best then
best = score
end
end
println(best) -- 2.0
-- Integer arithmetic wraps at the ends of the range rather than trapping.
println(Math.maxinteger + 1 == Math.mininteger) -- true
Math.floor<N>(n: N) -> integer

Rounds toward negative infinity. An integer comes back unchanged.

println(Math.floor(2.9)) -- 2
println(Math.floor(2.1)) -- 2
println(Math.floor(-2.1)) -- -3, toward -inf, not toward zero
println(Math.floor(5)) -- 5, already an integer
Math.ceil<N>(n: N) -> integer

Rounds toward positive infinity.

println(Math.ceil(2.1)) -- 3
println(Math.ceil(-2.9)) -- -2
println(Math.ceil(5)) -- 5
-- How many pages of ten a list needs.
local items: integer = 23
println(Math.ceil((items as float) / 10.0)) -- 3
Math.round<N>(n: N) -> integer

Rounds to the nearest integer, with a half rounding away from zero — 2.5 goes to 3 and -2.5 to -3. This is not banker’s rounding: a .5 never rounds toward even.

println(Math.round(2.4)) -- 2
println(Math.round(2.5)) -- 3, away from zero
println(Math.round(-2.5)) -- -3
println(Math.round(3.5)) -- 4
Math.sign<N>(n: N) -> integer

-1, 0 or 1. Always an integer, whatever flavour went in.

println(Math.sign(-8)) -- -1
println(Math.sign(0)) -- 0
println(Math.sign(2.5)) -- 1
-- Stepping toward a target without branching on direction.
local current: integer = 3
local target: integer = 10
println(current + Math.sign(target - current)) -- 4
Math.type(v: any) -> string?

"integer" or "float" for a number, nil for anything else. The way to ask which flavour a value that arrived through any actually has.

println(Math.type(1) ?? "?") -- integer
println(Math.type(1.0) ?? "?") -- float
println(Math.type("1") == nil) -- true, not a number
-- Which flavour arrived.
local value: any = 2.5
if (Math.type(value) ?? "") == "float" then
println("a float: " .. ((value as float) ?? 0.0)) -- a float: 2.5
end
Math.abs<N>(n: N) -> N

Magnitude, in the flavour it was given.

println(Math.abs(-8)) -- 8, an integer
println(Math.abs(-2.5)) -- 2.5, a float
println(Math.abs(0)) -- 0
-- Distance between two numbers, direction discarded.
println(Math.abs(3 - 10)) -- 7
Math.min<N>(...N) -> N

The smallest of its arguments, which must all share one flavour.

println(Math.min(3, 1, 7)) -- 1
println(Math.min(2.5, 1.5)) -- 1.5
println(Math.min(5)) -- 5, one argument is fine
Math.max<N>(...N) -> N

The largest of its arguments.

println(Math.max(3, 1, 7)) -- 7
println(Math.max(2.5, 1.5)) -- 2.5
-- A floor under a computed value, without an `if`.
local damage: integer = 2
println(Math.max(damage - 5, 0)) -- 0
Math.clamp<N>(n: N, lo: N, hi: N) -> N

Pins n into the inclusive range [lo, hi].

println(Math.clamp(137, 0, 100)) -- 100
println(Math.clamp(-5, 0, 100)) -- 0
println(Math.clamp(42, 0, 100)) -- 42, already inside
-- Keeping a value in range as it changes.
local health: integer = 95
health = Math.clamp(health + 20, 0, 100)
println(health) -- 100
Math.fmod<N>(a: N, b: N) -> N

The remainder after truncated division, in the flavour it was given. The sign follows the left operand, which is also what the % operator does — unlike Lua, where % floors and takes the sign of the right one.

println(Math.fmod(7, 3)) -- 1
println(Math.fmod(-7, 3)) -- -1, the sign of the left operand
println(-7 % 3) -- -1, the operator agrees
println(Math.fmod(7.5, 2.0)) -- 1.5
Math.modf<N>(n: N) -> table<float>

Splits a number into its integer and fractional parts, as a two-element table of floats.

local parts: table<float> = Math.modf(2.75)
println(parts[1] ?? 0.0) -- 2.0, the integer part
println(parts[2] ?? 0.0) -- 0.75, the fraction
-- Both parts carry the sign.
local neg: table<float> = Math.modf(-2.75)
println(neg[1] ?? 0.0) -- -2.0
Math.pow<N>(base: N, exp: N) -> float

base raised to the power exp. Always a float, which is what lets it answer a negative exponent — 2 ^ -1 is an error on integers, while Math.pow(2, -1) is 0.5.

println(Math.pow(2, 10)) -- 1024.0, a float even from integers
println(Math.pow(2, -1)) -- 0.5, where `2 ^ -1` is an error
println(Math.pow(9.0, 0.5)) -- 3.0, both arguments share one flavour
Math.sqrt<N>(n: N) -> float

The square root.

println(Math.sqrt(9)) -- 3.0
println(Math.sqrt(2)) -- 1.4142135623730951
-- The hypotenuse of a right triangle.
local a: float = 3.0
local b: float = 4.0
println(Math.sqrt(a * a + b * b)) -- 5.0
Math.exp<N>(n: N) -> float

e raised to the power n.

println(Math.exp(0)) -- 1.0
println(Math.exp(1)) -- 2.718281828459045
println(Math.exp(1) == Math.e) -- true
Math.log<N>(n: N, base: N?) -> float

The natural logarithm, or the logarithm in base when one is given.

println(Math.log(Math.e)) -- 1.0, natural by default
println(Math.log(8, 2)) -- 3.0, base 2
-- Floating point: a logarithm that "should" be exact often is not, so
-- compare with a tolerance rather than with `==`.
println(Math.log(1000, 10)) -- 2.9999999999999996
-- How many bits a value needs.
println(Math.ceil(Math.log(300, 2))) -- 9

All of these take an integer or a float and answer a float — an angle is a real number, so there is no integer-in / integer-out form the way there is for abs or clamp.

Math.sin<N>(n: N) -> float

Sine of n radians.

println(Math.sin(0)) -- 0.0
println(Math.sin(Math.pi / 2.0)) -- 1.0
-- Degrees go through `rad` first.
println(Math.sin(Math.rad(90))) -- 1.0
Math.cos<N>(n: N) -> float

Cosine of n radians.

println(Math.cos(0)) -- 1.0
println(Math.cos(Math.pi)) -- -1.0
-- A point on the unit circle, a third of the way round.
local angle: float = Math.rad(120)
println(Math.round(Math.cos(angle) * 100.0)) -- -50
Math.tan<N>(n: N) -> float

Tangent of n radians.

println(Math.tan(0)) -- 0.0
println(Math.round(Math.tan(Math.rad(45)))) -- 1
Math.asin<N>(n: N) -> float

Arcsine, in radians. n outside [-1, 1] has no answer and gives nan.

println(Math.asin(0)) -- 0.0
println(Math.asin(1) == Math.pi / 2.0) -- true
Math.acos<N>(n: N) -> float

Arccosine, in radians.

println(Math.acos(1)) -- 0.0
println(Math.acos(0) == Math.pi / 2.0) -- true
Math.atan<N>(y: N, x: N?) -> float

Arctangent, in radians. With one argument it is the arctangent of y; with two it is the atan2 form — the angle to the point (x, y), with the quadrant resolved correctly, which a single-argument arctangent cannot do.

println(Math.atan(0)) -- 0.0
println(Math.atan(1, 1) == Math.pi / 4.0) -- true, 45°
-- The quadrant is what the two-argument form is for.
println(Math.round(Math.deg(Math.atan(1, -1)))) -- 135
println(Math.round(Math.deg(Math.atan(-1, -1)))) -- -135
Math.deg<N>(radians: N) -> float

Radians to degrees.

println(Math.deg(Math.pi)) -- 180.0
println(Math.deg(Math.pi / 2.0)) -- 90.0
Math.rad<N>(degrees: N) -> float

Degrees to radians.

println(Math.rad(180)) -- 3.141592653589793
println(Math.rad(180) == Math.pi) -- true
-- Round tripping.
println(Math.deg(Math.rad(45))) -- 45.0
Math.random() -> float

A uniform float in [0, 1) — zero is possible, one is not.

Math.randomseed(42)
local r: float = Math.random()
println(r >= 0.0 and r < 1.0) -- true
Math.random(n: integer) -> integer

A uniform integer in [1, n], both ends included.

Math.randomseed(42)
local roll: integer = Math.random(6)
println(roll >= 1 and roll <= 6) -- true
Math.random(lo: integer, hi: integer) -> integer

A uniform integer in [lo, hi], both ends included.

Math.randomseed(42)
local n: integer = Math.random(10, 20)
println(n >= 10 and n <= 20) -- true
-- Picking an element out of a table.
local names: table<string> = {"ada", "grace", "alan"}
println(names[Math.random(1, #names)] != nil) -- true
Math.randomseed(seed: integer) -> nil

Resets the generator, so a sequence of Math.random calls repeats exactly. This is what makes a test or a procedurally generated world reproducible.

Math.randomseed(42)
local first: integer = Math.random(1000)
Math.randomseed(42)
local again: integer = Math.random(1000)
println(first == again) -- true, the same seed gives the same sequence
Math.ult(a: integer, b: integer) -> boolean

Compares two integers as unsigned 64-bit values. A negative integer has its top bit set, so it reads as a very large unsigned number.

println(Math.ult(1, 2)) -- true
println(Math.ult(-1, 1)) -- false: -1 is the largest unsigned value
println(1 < 2) -- true, the signed comparison agrees here
println(-1 < 1) -- true, where the signed one differs