Skip to content

create.math

Maths: Point2D, Vector2D and Matrix, Random and Noise, easing and Tween, scalar helpers, PI and TAU, and the common std.math functions.

lerp

def lerp(a: Float64, b: Float64, t: Float64) -> Float64

Blend from a to b, t running 0 to 1. Not clamped: t outside that range extrapolates past either end.

map

def map(
    value: Float64,
    in_low: Float64,
    in_high: Float64,
    out_low: Float64,
    out_high: Float64,
) -> Float64

Re-scale value from one range onto another — a slider position to a world coordinate, say. Linear and unclamped.

inverse_lerp

def inverse_lerp(value: Float64, low: Float64, high: Float64) -> Float64

Where value sits in [low, high] as a 0-to-1 fraction. The inverse of lerp, and map with a 0..1 output range.

smoothstep

def smoothstep(edge0: Float64, edge1: Float64, x: Float64) -> Float64

Like inverse_lerp, but clamped to 0..1 and eased at both ends — the usual choice for a transition that should not start or stop abruptly.

sign

def sign(x: Float64) -> Float64

-1.0, 0.0 or 1.0 by the sign of x. Zero is its own case, not positive.

fractional_part

def fractional_part(x: Float64) -> Float64

The part after the decimal point, always positive: fractional_part(-0.25) is 0.75, since it floors rather than truncating.

wrap

def wrap(value: Float64, period: Float64) -> Float64

value wrapped into [0, period). Floored, so a negative value wraps forward instead of staying negative — what an angle or a scrolling offset wants.

degrees

def degrees(radians: Float64) -> Float64

radians in degrees. Rotations take radians, so this is for display.

radians

def radians(degrees: Float64) -> Float64

degrees in radians, for feeding rotate an angle written in degrees.

Easing

struct Easing

Which curve ease applies. A Tween stores one as a field.

A wrapped Int rather than a bare one so a stray number cannot be passed where a curve is expected.

Implements Copyable, Deinitable, Equatable, ImplicitlyCopyable, Movable, Writable.

Constants

  • LINEAR = Easing(Int(0)): No easing — the fraction passes through unchanged.
  • IN_QUAD = Easing(Int(1))
  • OUT_QUAD = Easing(Int(2))
  • IN_OUT_QUAD = Easing(Int(3))
  • IN_CUBIC = Easing(Int(4))
  • OUT_CUBIC = Easing(Int(5))
  • IN_OUT_CUBIC = Easing(Int(6))
  • IN_SINE = Easing(Int(7))
  • OUT_SINE = Easing(Int(8))
  • IN_OUT_SINE = Easing(Int(9))
  • IN_EXPO = Easing(Int(10))
  • OUT_EXPO = Easing(Int(11))
  • IN_OUT_EXPO = Easing(Int(12))
  • IN_BACK = Easing(Int(13))
  • OUT_BACK = Easing(Int(14))
  • IN_OUT_BACK = Easing(Int(15))
  • IN_ELASTIC = Easing(Int(16))
  • OUT_ELASTIC = Easing(Int(17))
  • IN_OUT_ELASTIC = Easing(Int(18))
  • IN_BOUNCE = Easing(Int(19))
  • OUT_BOUNCE = Easing(Int(20))
  • IN_OUT_BOUNCE = Easing(Int(21))

Fields

  • value: Int

Constructors

@staticmethod
def __init__(out self, value: Int)

__eq__

def __eq__(self, other: Self) -> Bool

__ne__

def __ne__(self, other: Self) -> Bool

ease

def ease(curve: Easing, t: Float64) -> Float64

Reshape the fraction t by curve.

t is clamped to 0..1 first — unlike lerp, easing does not extrapolate. The curves are not linear, so an out-of-range input does not continue the motion sensibly: IN_EXPO at t = 2.0 would return 1024.

The BACK and ELASTIC curves deliberately leave 0..1 in the middle of their run, overshooting the target before settling. Anything consuming the result as a colour channel or an index has to clamp it.

Tween

struct Tween

A value moving from one number to another over a fixed duration.

A tween is a playhead, in the same sense as Animator: the program owns one per thing being animated and ticks it once per frame with the frame delta.

@staticmethod
def create(mut context: Context, mut canvas: Canvas) raises -> Self:
    return Self(Tween(0.0, 1.0, 0.4, Easing.OUT_CUBIC))

def update(mut self, mut context: Context, mut canvas: Canvas) raises:
    self.fade.update(context.time.delta)
    if context.keyboard.pressed(Key.SPACE):
        self.fade.play()
    canvas.fill(Color(255, 255, 255, Int(255 * self.fade.value())))

Unlike an animation, a tween carries no shared artwork -- its whole definition is four numbers -- so there is no asset to hold by ArcPointer and every entity simply owns its own.

Tweening something that is not a number. Tween(duration) runs 0 to 1, which is exactly what Vector2D.lerp and Color.lerp take, so one scalar tween drives a position or a colour without a second type:

self.slide = Tween(0.3, Easing.OUT_CUBIC)      # in create
...
self.position = self.from_position.lerp(self.to_position, self.slide.value())

Constructed stopped at start -- nothing moves until play, loop or ping_pong. The verbs mean what they mean on Animator: play always restarts from the beginning, pause freezes where it is and resume continues from there.

Implements Copyable, Deinitable, ImplicitlyCopyable, Movable, Writable.

Fields

  • start: Float64: The value at progress 0.
  • end: Float64: The value at progress 1.
  • duration: Float64: Seconds for a full run, start to end.
  • curve: Easing: The easing curve applied to progress to get value.

Constructors

@staticmethod
def __init__(
    out self,
    start: Float64,
    end: Float64,
    duration: Float64,
    curve: Easing = Easing.LINEAR,
)

A tween from start to end over duration seconds.

@staticmethod
def __init__(out self, duration: Float64, curve: Easing = Easing.LINEAR)

A tween from 0 to 1 -- the fraction Vector2D.lerp and Color.lerp take, and the form to reach for when animating anything that is not a plain number.

value

def value(self) -> Float64

The eased value, between start and end. Read this after update.

progress

def progress(self) -> Float64

How far through the duration, 0 to 1, before easing.

The raw time fraction, so it is what a progress bar wants to render -- and what value is derived from. While ping-ponging it runs back down to 0.

play

def play(mut self)

Run once from the start, holding end at the finish.

loop

def loop(mut self)

Run from the start, snapping back and repeating forever.

The snap is visible whenever start and end differ, which is what ping_pong exists to avoid -- reach for this only when the value wraps naturally, like a rotation through a full turn.

ping_pong

def ping_pong(mut self)

Run to end, back to start, forever -- the usual way to loop a tween, since it never snaps.

progress runs back down on the return leg, so the easing curve is travelled backwards too and a Easing.OUT_CUBIC pulse decelerates into both ends rather than into one.

pause

def pause(mut self)

Freeze at the current value. resume continues from here.

resume

def resume(mut self)

Continue a paused tween without rewinding.

stop

def stop(mut self)

Halt and rewind, putting value back to start.

update

def update(mut self, dt: Float64)

Advance the playhead by dt seconds. Call once per frame.

Nothing else moves a tween, so a tween never ticked is a tween stuck at start -- the same failure mode as an Animator that is never updated.

is_playing

def is_playing(self) -> Bool

True while the playhead is advancing -- false when stopped, paused or finished.

is_finished

def is_finished(self) -> Bool

True once a play reached end. Never true for loop or ping_pong, which have no end to reach.

Point2D

struct Point2D

A location in world space: distance, lerp, the affine operators, and an explicit constructor from a Vector2D for crossing between the two.

Every position in the library is one of these — canvas.circle(position, r), Rectangle.center(), context.mouse.position — and no signature takes a location as a loose x/y pair instead. The tuple constructors are @implicit, so canvas.circle((0, 0), 20) works without naming the type and a program only spells Point2D when it is storing one. It is the only type here that converts from a bare tuple, which is what keeps that unambiguous.

The surface is strictly affine, which is the whole point of the type existing beside Vector2D: subtracting two positions gives the displacement between them (Vector2D), and adding a displacement to a position gives another position. magnitude, normalize, dot, scalar *, unary - and Point2D + Point2D are absent on purpose — none of them means anything for a location, and position.normalize() compiling was the mistake this split exists to catch. A program that genuinely wants a position's components as a direction says so: Vector2D(p).

A displacement is always named: p + Vector2D(1, 2) moves a position. Mind p - (1, 2): the tuple can only become a Point2D, so it is the displacement from (1, 2) to p -- a Vector2D, not a moved position. Moving backwards is p - Vector2D(1, 2).

There are no zero()/one() factories either. (0, 0) through the implicit constructor is shorter than any name for the origin, and one() has no meaning for a position at all.

Crossing to another type is an explicit constructor, never an implicit one: Vector2D(p) is the displacement from the origin to p, and Point2D(v) the origin moved by v. Naming the target type is what keeps the conversion visible; an @implicit one would undo the split. xy returns the components as a plain tuple, to destructure: var (x, y) = p.xy().

Implements Copyable, Deinitable, Equatable, ImplicitlyCopyable, Movable, Writable.

Fields

  • x: Float64
  • y: Float64

Constructors

@staticmethod
def __init__(out self, x: Float64, y: Float64)
@staticmethod
def __init__(out self, x: Int, y: Int)
@staticmethod
def __init__(out self, t: Tuple[Float64, Float64])
@staticmethod
def __init__(out self, t: Tuple[Int, Int])
@staticmethod
def __init__(out self, t: Tuple[Int, Float64])
@staticmethod
def __init__(out self, t: Tuple[Float64, Int])
@staticmethod
def __init__(out self, v: Vector2D)

The origin moved by v: the position v points to.

__eq__

def __eq__(self, other: Self) -> Bool

__ne__

def __ne__(self, other: Self) -> Bool

__add__

def __add__(self, v: Vector2D) -> Self

__sub__

def __sub__(self, other: Self) -> Vector2D

The displacement from other to self.

def __sub__(self, v: Vector2D) -> Self

__iadd__

def __iadd__(mut self, v: Vector2D)

__isub__

def __isub__(mut self, v: Vector2D)

distance

def distance(self, other: Self) -> Float64

distance_squared

def distance_squared(self, other: Self) -> Float64

lerp

def lerp(self, other: Self, t: Float64) -> Self

A new point t of the way from this one to other; this one is unchanged. Not clamped: t outside 0..1 carries on past either end.

xy

def xy(self) -> Tuple[Float64, Float64]

Vector2D

struct Vector2D

A 2D displacement or direction: arithmetic operators, magnitude, normalize, dot, distance, lerp, the angle helpers from_angle, angle and rotate, the length helpers with_length and limit, and an explicit constructor from a Point2D.

Unlike Point2D, a bare tuple does not convert to one implicitly: a displacement literal names its type, rect.translate(Vector2D(3, 4)). Only one type taking a bare tuple is what keeps canvas.circle((0, 0), 20) unambiguous, and locations are the literals a program writes most; displacements mostly arrive from arithmetic (b - a, vel * dt), which already yields a Vector2D.

Crossing to another type is an explicit constructor: Vector2D(p) is the displacement from the origin to the position p, and Point2D(v) the reverse. Naming the target type is what keeps the conversion visible. xy returns the components as a plain tuple, to destructure: var (x, y) = v.xy().

Angles are radians counter-clockwise from +x, as everywhere in the library (y is up): Vector2D.from_angle(a) * speed aims, v.angle() reads the heading back, v.rotate(a) turns. Like normalize, every helper returns a new vector and leaves self alone.

The direction constants follow the screen's axes, y up: UP is Vector2D(0, 1) and DOWN is Vector2D(0, -1), so gravity is Vector2D.DOWN * 9.81.

Implements Copyable, Deinitable, Equatable, ImplicitlyCopyable, Movable, Writable.

Constants

  • ZERO = Vector2D(SIMD(0), SIMD(0))
  • ONE = Vector2D(SIMD(1), SIMD(1))
  • UP = Vector2D(SIMD(0), SIMD(1))
  • DOWN = Vector2D(SIMD(0), SIMD(-1))
  • LEFT = Vector2D(SIMD(-1), SIMD(0))
  • RIGHT = Vector2D(SIMD(1), SIMD(0))

Fields

  • x: Float64
  • y: Float64

Constructors

@staticmethod
def __init__(out self, x: Float64, y: Float64)
@staticmethod
def __init__(out self, x: Int, y: Int)
@staticmethod
def __init__(out self, t: Tuple[Float64, Float64])
@staticmethod
def __init__(out self, p: Point2D)

The displacement from the origin to p.

__neg__

def __neg__(self) -> Self

__eq__

def __eq__(self, other: Self) -> Bool

__ne__

def __ne__(self, other: Self) -> Bool

__add__

def __add__(self, other: Self) -> Self

__sub__

def __sub__(self, other: Self) -> Self

__mul__

def __mul__(self, s: Float64) -> Self

__truediv__

def __truediv__(self, s: Float64) -> Self

__rmul__

def __rmul__(self, s: Float64) -> Self

s * v, the same as v * s, so a formula reads as written.

__iadd__

def __iadd__(mut self, other: Self)

__isub__

def __isub__(mut self, other: Self)

__imul__

def __imul__(mut self, s: Float64)

__itruediv__

def __itruediv__(mut self, s: Float64)

magnitude

def magnitude(self) -> Float64

magnitude_squared

def magnitude_squared(self) -> Float64

normalize

def normalize(self) -> Self

dot

def dot(self, other: Self) -> Float64

distance

def distance(self, other: Self) -> Float64

distance_squared

def distance_squared(self, other: Self) -> Float64

lerp

def lerp(self, other: Self, t: Float64) -> Self

A new vector t of the way from this one to other; this one is unchanged. Not clamped: t outside 0..1 carries on past either end.

from_angle

@staticmethod
def from_angle(angle: Float64, length: Float64 = 1) -> Self

The vector of length pointing at angle radians, counter-clockwise from +x: from_angle(0) is RIGHT.

angle

def angle(self) -> Float64

The heading in radians, counter-clockwise from +x, in [-PI, PI]. 0 for the zero vector.

rotate

def rotate(self, angle: Float64) -> Self

This vector turned by angle radians, counter-clockwise; its length is unchanged.

with_length

def with_length(self, length: Float64) -> Self

This direction at length. Like normalize, the zero vector has no direction and gives NaN.

limit

def limit(self, max_length: Float64) -> Self

This vector, shortened to max_length if it is longer — a speed cap. Shorter vectors, the zero vector among them, are unchanged.

xy

def xy(self) -> Tuple[Float64, Float64]

Matrix

struct Matrix[rows: Int, cols: Int]

A fixed-size matrix, row-major, sized at compile time.

Transforms are homogeneous, so the library's 2D transforms are Matrix[3, 3] — the extra row and column is what lets a translation be a multiplication like any other, and so compose into a single matrix.

Build them with the free functions below rather than by hand: identity, translate, rotate, scale. Compose with @, which applies right to left — translate(x, y) @ rotate(a) rotates first, then moves the rotated result. apply(m, point) maps a Point2D through it, translation included; apply(m, vector) maps a Vector2D, which a translation does not move.

Implements Copyable, Deinitable, Equatable, ImplicitlyCopyable, Movable, Writable.

Fields

  • data: Array[Float64, (rows * cols)]

Constructors

@staticmethod
def __init__(out self)

All zeros.

@staticmethod
def __init__(out self, *values: Float64)

The entries, row by row: Matrix[2, 2](1.0, 2.0, 3.0, 4.0) has 1.0, 2.0 on top. Takes exactly rows * cols values.

@staticmethod
def __init__(out self, *, copy: Self)

__getitem__

def __getitem__(self, r: Int, c: Int) -> Float64

__setitem__

def __setitem__(mut self, r: Int, c: Int, val: Float64)

__eq__

def __eq__(self, other: Self) -> Bool

__ne__

def __ne__(self, other: Self) -> Bool

__matmul__

def __matmul__[P: Int](self, rhs: Matrix[cols, P]) -> Matrix[rows, P]

transpose

def transpose(self) -> Matrix[cols, rows]

Rows and columns swapped, as a new matrix.

identity

def identity[N: Int]() -> Matrix[N, N]

The N x N transform that changes nothing — the start of a composition.

inverse

def inverse[N: Int](m: Matrix[N, N]) -> Matrix[N, N]

The transform that undoes m — Gauss-Jordan with partial pivoting.

Mapping a point back out of a transformed frame is what this is for, which is how canvas.to_local maps a mouse position into the current transform. A singular matrix (a zero scale, say) has no inverse and the result is meaningless rather than an error.

apply

def apply(m: Matrix[Int(3), Int(3)], point: Point2D) -> Point2D

point mapped through m, translation included.

def apply(m: Matrix[Int(3), Int(3)], vector: Vector2D) -> Vector2D

vector mapped through m's rotation, scale and shear, but not its translation: a displacement, unlike a position, is the same wherever it starts. So a velocity turns with a rotate and stretches with a scale, and a translate leaves it as it is. Meant for the affine matrices translate, rotate and scale build; a projective bottom row is ignored.

translate

def translate(dx: Float64, dy: Float64) -> Matrix[Int(3), Int(3)]

Move by (dx, dy) in world units. dy is up, since y grows upward.

def translate(delta: Vector2D) -> Matrix[Int(3), Int(3)]

Move by delta in world units, as shape.translate(delta) does.

rotate

def rotate(angle: Float64) -> Matrix[Int(3), Int(3)]

Turn by angle radians about the origin, counter-clockwise — the mathematical convention, which holds here because y grows upward. Use radians(d) for an angle written in degrees.

scale

def scale(sx: Float64, sy: Float64) -> Matrix[Int(3), Int(3)]

Scale each axis about the origin. Negative values mirror.

def scale(s: Float64) -> Matrix[Int(3), Int(3)]

Scale both axes by the same factor.

Random

struct Random

A seeded pseudo-random generator: .float(), .int(), .bool().

Random() seeds itself from the clock and Random(seed) takes one, which is the difference that matters: a seeded generator replays the same sequence every run, so a procedurally generated level or a particle burst can be reproduced exactly while it is being tuned.

xoroshiro128++ — fast and well-distributed, not cryptographic. Don't render keys or tokens from it.

Implements Deinitable, Movable.

Constructors

@staticmethod
def __init__(out self)
@staticmethod
def __init__(out self, seed: UInt64)

float

def float(mut self) -> Float64

A number in [0.0, 1.0) — 1.0 itself is never returned.

def float(mut self, low: Float64, high: Float64) -> Float64

A number in [low, high).

int

def int(mut self, low: Int, high: Int) -> Int

A whole number in [low, high) — high is excluded, so r.int(0, len(items)) indexes a list without going off the end.

bool

def bool(mut self) -> Bool

A coin flip — True half the time.

Noise

struct Noise

Smooth pseudo-random values: .at(x), .at(position) and .at(position, time), each in [0.0, 1.0].

Where Random gives independent samples that jump from one call to the next, Noise is a field: nearby inputs give nearby values, so a terrain height, a wandering position or a flow-field angle drifts instead of jittering. The range is Random.float()'s, so it swaps in wherever the jitter should be smooth; lerp(-a, a, n) makes it signed.

Noise() seeds itself from the clock and Noise(seed) takes one; the same seed always gives the same field. Nothing changes after construction, so .at reads rather than advances, and a copy is the same field.

feature_size is the distance, in the units passed to .at, over which the field goes from one value to an unrelated one: hills about 120 units across are Noise(feature_size=120). It divides x and position but not time, so an animation's rate is set at the call, noise.at(position, time=t * 0.3), independently of the spatial scale.

octaves layers the field over itself at doubling frequencies, each layer's weight falloff times the last: one octave is plain, blobby Perlin; the default four at 0.5 add the finer detail of a coastline or a cloud edge. The layers are averaged by weight, so the range stays [0, 1].

Improved Perlin noise (2002). Its value at a whole-number lattice point, after dividing by feature_size, is exactly 0.5.

Implements Copyable, Deinitable, Movable, Writable.

Constructors

@staticmethod
def __init__(
    out self,
    *,
    octaves: Int = Int(4),
    falloff: Float64 = 0.5,
    feature_size: Float64 = 1,
)
@staticmethod
def __init__(
    out self,
    seed: UInt64,
    *,
    octaves: Int = Int(4),
    falloff: Float64 = 0.5,
    feature_size: Float64 = 1,
)

at

def at(self, x: Float64) -> Float64

The field along a line: a value that wanders smoothly as x (a time, say) moves.

def at(self, position: Point2D) -> Float64

The field over the plane: terrain, texture, a flow-field angle.

def at(self, position: Point2D, time: Float64) -> Float64

The plane field, animated: it evolves smoothly as time moves. time is not divided by feature_size; scale it at the call.

PI

comptime PI = pi

Half a turn in radians.

TAU

comptime TAU = tau

A whole turn in radians: 2 * PI.

From the standard library

Re-exported from std.math, so a program has them without a second import: clamp, sqrt, floor, ceil, sin, cos, tan, asin, acos, atan2, exp, log, log2, isnan, isinf, isfinite.