fixstep guide

← fixstep · API reference · source

fixstep owns the arithmetic of a fixed simulation step: how many steps a frame's elapsed time buys, how far between two steps the present is, how much ground a rate covers, and when a one-shot cooldown fires. It keeps every duration as an exact INTEGER count of one base unit, so nothing drifts and nothing is lost to rounding; floating point appears only where something is drawn. What a step DOES is yours — this never calls your code, it answers "how many".

use fixstep::*;

60 steps a second, in the base unit CLOCK_UNITS_PER_SECOND (3 000 000 to the second, so 24, 25, 30, 50, 60 and 120 Hz are all whole numbers of units).

STEP = 50000;
fn main() {
  clk = clock_new(STEP);

A frame of 1/30 s is 100 000 units: two whole steps.

  n = clock_advance(clk, 100000);
  println("a 1/30 s frame buys {n} steps");
  assert(STEP == CLOCK_UNITS_PER_SECOND / 60 && n == 2, "two 60 Hz steps in a 30 Hz frame");

Every section below is a function, and main calls each one: a section nothing calls still compiles, but its assertions never run.

  the_remainder_is_banked();
  a_rate_covers_exact_ground();
  a_cooldown_fires_once();
  easing_is_for_drawing();
}

That prints:

  a 1/30 s frame buys 2 steps

The remainder is banked

A frame rarely holds a whole number of steps. clock_advance runs the steps it can and BANKS the rest for the next frame, so a 70 000-unit frame runs one step and keeps 20 000. clock_alpha says how far into the next step the present is — 0.4 here — which is what a renderer interpolates with; it is always below 1.0.

fn the_remainder_is_banked() {
  clk = clock_new(STEP);
  assert(clock_advance(clk, 70000) == 1, "one whole step");
  assert(clock_banked(clk) == 20000 && clock_alpha(clk) == 0.4, "20 000 kept, 0.4 of a step");
  assert(clock_advance(clk, 30000) == 1, "the bank and this frame make the next step");
  println("bank    : 70 000 units -> 1 step, alpha 0.4; 30 000 more -> 1 step");
}

A rate covers exact ground

A mover at 1.5 cells a second, ticked at 60 Hz, covers 0.025 of a cell per step — never a whole cell, so a mover that truncated would never move. A Bank accumulates the progress as an integer and releases WHOLE units as they complete; the remainder stays banked, so over two seconds the mover covers exactly 3 cells, and after one it has 1 with half a cell banked.

fn a_rate_covers_exact_ground() {
  b = bank_new();
  rate = bank_rate(1.5);
  moved = 0;
  for _ in 0..60 { moved += bank_gain(b, rate, STEP, BANK_WHOLE); }
  assert(moved == 1 && bank_fraction(b, BANK_WHOLE) == 0.5, "one second: 1 cell, half banked");
  for _ in 0..60 { moved += bank_gain(b, rate, STEP, BANK_WHOLE); }
  assert(moved == 3, "two seconds: exactly 3 cells");
  println("rate    : 1.5 cells/s for 2 s -> {moved} cells");
}

A cooldown fires once

A Timer is a one-shot: arm it with a duration, spend each tick's time into it, and timer_spend answers true on exactly the tick it completes — and then it is stopped, so it never fires again until it is armed again. A 0.25 s cooldown at 60 Hz fires on the 15th tick. (A Bank would carry its remainder into the next arming and fire on its own; the README's table says which type each job wants.)

fn a_cooldown_fires_once() {
  t = timer_armed(clock_units_from_seconds(0.25));
  fired_at = 0;
  for tick in 1..21 {
    if timer_spend(t, STEP) {
      assert(fired_at == 0, "only once");
      fired_at = tick;
    }
  }
  assert(fired_at == 15 && !timer_running(t), "on the 15th tick, then stopped");
  println("timer   : a 0.25 s cooldown fires on tick {fired_at}");
}

Easing is for drawing

approach(now, want, k, dt) moves a drawn value — a camera, a gauge — toward its target by the fraction k closes per second, independent of the frame rate. It is the one part of the package in floating point, because it only ever feeds a picture; a frame with no time in it (a paused game) does not move it at all.

fn easing_is_for_drawing() {
  v = approach(0.0, 10.0, 2.0, 0.5);
  assert(abs(v - 10.0 * (1.0 - exp(-1.0))) < 0.000000001, "10 * (1 - e^-1)");
  assert(approach(3.0, 10.0, 2.0, 0.0) == 3.0, "a paused frame does not creep");
  println("ease    : half a second at k=2 covers {v:.4} of 10");
}

Where to go next