fixedpoint

tape
import fixedpoint from "fixedpoint";

let dt: fixedpoint.Fixed = fixedpoint.from_ratio(1, 60);
var pos: fixedpoint.Vec2 = fixedpoint.vec2(fixedpoint.from_int(10), fixedpoint.ZERO);
let vel: fixedpoint.Vec2 = fixedpoint.from_angle(fixedpoint.from_ratio(3, 4)) * 5;
pos = pos + vel * dt;
io.println(pos.x.format(3));                     // 10.061

fixedpoint is for simulation that has to come out the same everywhere: a lockstep multiplayer game where every player’s machine runs the same inputs through the same step, a replay saved as inputs, a rollback, a saved game that must resume exactly. Its numbers are integers underneath, so every operation gives the same bits on every platform, in the VM and natively, and in any other program that follows the same rules.

A Fixed is a Q32.32 number: an i64 counting 2^-32ths. It runs from −2,147,483,648 to just under 2,147,483,648 in steps of about 2.3e-10, the same step size everywhere in the range (a position a million units from the origin is as precise as one next to it). It is a plain value: copy it, store it, compare it.

It is pure: no state, no I/O, no float operations inside. (Floats only appear where you convert in or out, below.)

The name is fixedpoint because fixed is the fixed-capacity containers.

Making numbers

tape
pub fn from_int(n: i64) -> Fixed
pub fn from_ratio(num: i64, den: i64) -> Fixed
pub fn from_raw(raw: i64) -> Fixed
pub const ZERO, ONE, HALF, TWO, EPSILON, MAX, MIN, PI, HALF_PI, TAU, E: Fixed

from_int(3) is 3; it traps unless the integer is in range. from_ratio(1, 10) is the nearest Fixed to 0.1: the way to write a constant in simulation code without a float. from_raw takes the underlying i64 as it is, and x.raw gives it back: hash it, save it, send it over the network, and the other side has exactly the same value.

tape
let gravity: fixedpoint.Fixed = fixedpoint.from_ratio(-981, 100);   // -9.81
let speed: fixedpoint.Fixed = fixedpoint.from_int(12);
let tiny: fixedpoint.Fixed = fixedpoint.EPSILON;                    // 2^-32, the smallest step

EPSILON is the smallest step, MAX and MIN the ends of the range, and PI, HALF_PI, TAU and E the nearest Fixed to each.

Arithmetic

+, -, *, /, %, unary -, ==, !=, <, <=, >, >= work on Fixed values. * and / also take an integer, on either side for *: x * 3, 3 * x, x / 4.

tape
let a: fixedpoint.Fixed = fixedpoint.from_ratio(7, 2);      // 3.5
let b: fixedpoint.Fixed = fixedpoint.from_int(2);
io.println("{(a * b).to_string()} {(a / b).to_string()} {(a % b).to_string()}");   // 7 1.75 1.5
io.println("{(a * 3).to_string()} {(a / 4).to_string()}");                         // 10.5 0.875

Rounding. A product or quotient that falls between two steps is rounded to the nearest one, and an exact tie goes to the even one. Every machine rounds the same way, and on average the errors cancel instead of drifting one way over thousands of steps. +, -, % and * by an integer are exact.

Overflow traps. A result outside the range stops the program with fixedpoint: overflow, and dividing by zero with fixedpoint: division by zero, the same on every machine. Nothing wraps around: a position that overflowed would otherwise jump to the other side of the world. When you expect to get near the limits, the checked forms give null where the operator would trap:

tape
pub fn Fixed.checked_add(self, o: Fixed) -> ?Fixed     // checked_sub, checked_mul, checked_div
tape
let big: fixedpoint.Fixed = fixedpoint.from_int(100000);
let p: ?fixedpoint.Fixed = big.checked_mul(big);       // 10^10 doesn't fit
if (p == null) { io.println("too big"); }

Rounding and limits

tape
pub fn Fixed.floor(self) -> Fixed           // ceil, trunc, round, fract
pub fn Fixed.abs(self) -> Fixed             // sign: -1, 0 or 1
pub fn Fixed.min(self, o: Fixed) -> Fixed   // max
pub fn Fixed.clamp(self, lo: Fixed, hi: Fixed) -> Fixed
pub fn Fixed.lerp(self, o: Fixed, t: Fixed) -> Fixed
pub fn Fixed.to_i64(self) -> i64

floor, ceil and trunc give whole numbers as a Fixed; round rounds halves away from zero (2.5 is 3, −2.5 is −3), like math.round_f64. fract is what floor removed, from 0 up to 1. lerp(o, t) is self at t = 0 and o at t = 1. to_i64 drops the fraction toward zero.

tape
let h: fixedpoint.Fixed = fixedpoint.from_ratio(-11, 4);   // -2.75
io.println("{h.floor().to_i64()} {h.ceil().to_i64()} {h.round().to_i64()} {h.fract().to_string()}");   // -3 -2 -3 0.25

Square root and trigonometry

tape
pub fn Fixed.sqrt(self) -> Fixed
pub fn Fixed.sin(self) -> Fixed            // cos, tan; radians
pub fn atan2(y: Fixed, x: Fixed) -> Fixed  // -pi to pi
pub fn Fixed.atan(self) -> Fixed           // asin, acos

sqrt is exact to the nearest step; it traps on a negative number. sin, cos and atan2 are computed with integer rotations (CORDIC) and integer tables, and come out within one step of the true value for every angle. atan2(0, -1) is PI, and the four axes give 0, HALF_PI, PI and -HALF_PI exactly. asin and acos trap outside −1 to 1.

tape
let r: fixedpoint.Fixed = fixedpoint.from_int(2).sqrt();
io.println(r.format(6));                                          // 1.414214
let turn: fixedpoint.Fixed = fixedpoint.atan2(fixedpoint.ONE, fixedpoint.ONE);
io.println(turn.format(6));                                       // 0.785398

Text

tape
pub fn parse(s: []const u8) -> Fixed or conv.ParseError
pub fn Fixed.to_string(self) -> string
pub fn Fixed.format(self, decimals: i64) -> string

parse reads a decimal number: an optional sign, digits, and optionally a point and more digits ("12", "-0.375", ".5"). No exponent. It rounds exactly, however many digits there are, so the same text gives the same number everywhere: a good way to put tuning values in a config file. It fails with conv’s ParseError: Empty, InvalidFormat, InvalidDigit or Overflow.

to_string writes the shortest decimal that reads back to the same value: "1", "0.5", "-1.25", "3.1415926537" for PI. format(d) writes exactly d digits after the point, 0 to 9, rounded to nearest (ties to even): PI.format(2) is "3.14".

tape
let jump: fixedpoint.Fixed = fixedpoint.parse("4.25") or { return 1; };
io.println("{jump.to_string()} {fixedpoint.PI.format(2)}");     // 4.25 3.14
let bad: fixedpoint.Fixed = fixedpoint.parse("1e3") or {
    io.println("not a number: {err}");                         // not a number: invalid digit
    return 1;
};

Vectors

tape
pub struct Vec2 { x: Fixed; y: Fixed; }
pub struct Vec3 { x: Fixed; y: Fixed; z: Fixed; }
pub fn vec2(x: Fixed, y: Fixed) -> Vec2        // vec3(x, y, z)

Vectors add and subtract, multiply and divide by a Fixed or an integer (v * 2, 2 * v, speed * v), negate and compare with ==. Both have:

tape
pub fn Vec2.dot(self, o: Vec2) -> Fixed
pub fn Vec2.length(self) -> Fixed          // length_sq
pub fn Vec2.distance(self, o: Vec2) -> Fixed   // distance_sq
pub fn Vec2.normalize(self) -> Vec2
pub fn Vec2.lerp(self, o: Vec2, t: Fixed) -> Vec2
pub fn Vec2.to_f32(self) -> math3d.Vec2
pub fn Vec3.cross(self, o: Vec3) -> Vec3

and Vec2 also has cross (a Fixed: positive when o is counterclockwise), perp (a quarter turn counterclockwise), angle, rotate(angle), and from_angle(angle) makes the unit vector at an angle.

dot, cross and length add up the exact products and round once, so they are as accurate as a single multiplication, and a length never overflows on the way: (10^9, 10^9) has length 1414213562.37. The zero vector normalizes to itself.

tape
let v: fixedpoint.Vec2 = fixedpoint.vec2(fixedpoint.from_int(3), fixedpoint.from_int(4));
io.println("{v.length().to_string()} {v.normalize().x.to_string()}");   // 5 0.6

Floats at the edges

tape
pub fn from_f64(x: f64) -> Fixed           // from_f32
pub fn Fixed.to_f64(self) -> f64           // to_f32

A float is only as reliable as where it came from. Convert floats in at load time, from values every machine has the same bits of (a literal, a level file), never from a float computed while the game runs. Convert out for drawing and sound (to_f32, Vec2.to_f32 gives a math3d vector), and don’t feed what you computed from it back into the simulation. from_f64 traps on NaN or a value out of range.

Keeping a game in step

Run the simulation on Fixed and its vectors only, starting from the same state, and apply the same inputs in the same order. Then every machine computes the same raw values, which you can check by hashing them:

tape
var h: u64 = 1469598103934665603;
h = (h ^ (pos.x.raw as u64)) * 1099511628211;
h = (h ^ (pos.y.raw as u64)) * 1099511628211;
// send h with the frame number; a peer whose hash differs has desynced

Last modified: