fixedpoint
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.061fixedpoint 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
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: Fixedfrom_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.
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 stepEPSILON 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.
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.875Rounding. 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:
pub fn Fixed.checked_add(self, o: Fixed) -> ?Fixed // checked_sub, checked_mul, checked_divlet 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
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) -> i64floor, 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.
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.25Square root and trigonometry
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, acossqrt 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.
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.785398Text
pub fn parse(s: []const u8) -> Fixed or conv.ParseError
pub fn Fixed.to_string(self) -> string
pub fn Fixed.format(self, decimals: i64) -> stringparse 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".
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
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:
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) -> Vec3and 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.
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.6Floats at the edges
pub fn from_f64(x: f64) -> Fixed // from_f32
pub fn Fixed.to_f64(self) -> f64 // to_f32A 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:
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 desyncedLast modified: