Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

11 — The tick

Time & passes phase

Concept node: see the DAG and glossary entry 11.

A program’s life has a shape:

  • Start-up — initialisation. Tables are allocated, inputs are opened, the RNG is seeded, the world reaches a known state.
  • Steps — ticks of the clock in a simulation, turns in a card game, event handlers in a server. The repeating unit of forward motion.
  • Save and load — the in-memory state is preserved to disk so a future run can resume from where this one left off. Optional, but if you want it, it lives here.
  • Exit — resources are returned to the kernel. Memory, file handles, sockets, lockfiles. Failure to do this cleanly is called a memory leak (or a stale lock, or a broken socket).

This section is about the step. The step is where the time budget binds, where the system DAG runs, where determinism either holds or breaks. The other phases are real and important — the book returns to save and load when persistence is named at §36, and exit is mostly the operating system’s job — but the inner step is what makes or breaks every other property the book builds on.

Each step is a tick. State at the start of a tick is read; state at the end is written; nothing is half-updated mid-tick. Even an interactive program — a card game waiting for the next move, a text editor waiting for a keystroke — is a tick loop, just with an external trigger driving it. A program that does a single pass over a file and exits is a degenerate tick loop with one tick: it has the same start-of-tick / end-of-tick contract, just with N=1.

Ticks come in two natural shapes.

A time-driven tick fires at a fixed rate. The simulator from code/sim/SPEC.md runs at 30 Hz: one tick every 33 ms. The loop wakes up, advances every system by one step, sleeps until the next tick. Most simulations, games, control loops, audio engines, and animation systems are time-driven. The rate is a contract with the rest of the world: at this rate, output appears.

A turn-based tick fires when an event arrives. A card game ticks when a player makes a move. A chess engine ticks when its opponent moves. A discrete-event simulator ticks at the timestamp of the next pending event, however far in the future that is. The clock advances with the events, not under them. Turn-based ticks have no fixed rate; their pace is set by the input stream.

Both are ticks. The difference is what triggers the next pass:

#![allow(unused)]
fn main() {
// time-driven
use std::time::{Duration, Instant};
const TICK: Duration = Duration::from_millis(33);

loop {
    let start = Instant::now();
    run_all_systems(&mut world);
    let elapsed = start.elapsed();
    if elapsed < TICK {
        std::thread::sleep(TICK - elapsed);
    }
}
}
#![allow(unused)]
fn main() {
// turn-based
loop {
    let event = wait_for_next_event();
    apply_event(&mut world, event);
}
}

The §0 simulator runs time-driven. The card game from §5 ran turn-based — every card you dealt was one tick. Both are valid; both fit the same framework.

Within each tick, the systems run in an order specified by the system DAG (§14’s topic). Each tick has a budget: 33 ms at 30 Hz, the ms-per-move in a card game played at human speed. The budget binds the design: at 30 Hz with 1 000 000 creatures, each motion update has 33 nanoseconds, which only fits if the data layout cooperates (§4 made this precise).

A subtle pitfall worth naming. Mixing turn-based and time-driven thinking in the same loop produces drift: the turn-based subsystem’s pace bleeds into the time-driven subsystem’s budget. The fix is to keep the two cleanly separated — typically, one outer loop and the other as an event source feeding it.

A tick is the unit of forward motion in any program that has forward motion. The next sections name what fits in one tick, in what order, and what does not.

Exercises

You will need a minimal Rust project for these. cargo new tick_lab is enough.

  1. A 30 Hz time-driven loop. Write a main that loops at 30 Hz. Each iteration, print the elapsed time since program start. Sleep between ticks to maintain the rate. Run it for 10 seconds. Did you actually get 300 iterations?
  2. The naive sleep mistake. Replace your sleep logic with std::thread::sleep(Duration::from_millis(33)) (no measurement). Run for 30 seconds. Does the program drift over time? Why?
  3. Dropped frames. Inside the loop, sleep for 50 ms — longer than the budget. The loop is now running at 20 Hz; it has missed frames. Print a warning when this happens.
  4. A turn-based loop. Write a tiny REPL: print > , read a line, print you said: <line>. Each line is one tick. Run it. Note that the loop has no fixed rate — its pace is your typing.
  5. Mixing the two. Modify exercise 4 so that, while waiting for input, the program also prints the current second once per second. (Hint: spawn a thread, use a non-blocking read, or interleave with timeouts.) Note how mixing the two patterns adds complexity quickly.
  6. (stretch) A discrete-event tick loop. Maintain a Vec<(f64, String)> of (timestamp, message) events. Pop the smallest-timestamp event, advance a “simulation clock” to that timestamp, print the message, repeat until the queue is empty. This is the structure of a discrete-event simulator and a preview of §12.

Reference notes in 11_the_tick_solutions.md.

What’s next

Exercise 6 hints at the next section. The clock can live on the events themselves, independent of how often the loop fires. §12 — Event time is separate from tick time names that separation.