The Tick: Pure Game Loops
Sixty times a second, a game computes the next world from the current one. Make that computation a pure function and you get testing, replays and multiplayer almost for free.
You will be able to…
- Model a moving world as a record and advance it with a pure
step - Use
dtso speed means "per second", not "per frame" - Drive it all with
Game.run ~init ~step ~draw— and watch it live - Explain determinism: same inputs, same game, every time
A game is a film
A running game is a flip-book: still frames, shown fast. Between two
frames, something must answer one question — given the world as it
was, what is the world now? In Camel Quest that answer is always a
pure function we call the tick:
step : float -> world -> world. The float is
dt, the time since the last frame in seconds — about
0.016 at 60 fps, exactly the delta time you met with
Anim.start in lesson 1.6. Multiply speeds by
dt and they become "pixels per second": frames may come
fast or slow, your ball flies at the same speed. Here's a whole world
and its tick — stepped by hand, no animation yet:
A quarter-second per tick for easy mental maths: 100 pixels per
second × 0.25 s = 25 pixels per tick. And look at what
didn't happen: w0 still reads
{x = 0.; …}. Three photographs, not one repainted canvas.
The film strip of your game is a chain of immutable worlds —
step just makes the next photo.
Break it on purpose: ints vs floats, again
Positions and speeds are floats (motion between pixels must be smooth), so the dotted operators are back. Swap one out and read the complaint:
The compiler aims straight at it: the field access
w.x has type float but an expression was
expected of type int — because bare +
only adds ints. Since lesson 1.1 this rule hasn't changed; by now it's
catching your physics bugs. The fix is one dot: +.
Bounce: a whole game rule in one function
Time to write a real tick. A ball drifts along x; the court runs from
wall 0. to wall 380. (that's the canvas width
minus the ball's 20-pixel width). The rule, precisely:
- Tentatively move:
x' = w.x +. w.vx *. dt - If
x' < 0.— land on0.and flip the speed:{ x = 0.; vx = -. w.vx } - Else if
x' > 380.— land on380.and flip:{ x = 380.; vx = -. w.vx } - Otherwise — glide: position
x', samevx
Implement step_ball : float -> world -> world
following the spec above, exactly. -. w.vx is the
flipped speed. The tests probe mid-flight, both walls, and a frozen
dt = 0.
x' once with let x' = … in, then decide with if/else if/else.with update of x only.+. and *. everywhere — and 0., not 0.Watch it fly: Game.run
Your tick is tested — now let's see it. The Gamekit's game loop wants
three things: init, the first world; step,
your pure tick; and draw, which turns a world into pixels.
Game.run ~init ~step ~draw wires them into the flip-book:
draw the world, compute the next one, repeat. All the impurity — the
actual painting — is quarantined in draw; the rules of your
game never touch a pixel. Press Run and tweak: the speed, the
colours, the ball size…
Keep the paddle on the table
Next ingredient for any pong-like: a paddle that follows input but can't leave the court. "Pull a value back inside an interval" is called clamping, and it's two old friends from lesson 2.1 stacked:
Write clamp : float -> float -> float ->
float so that clamp lo hi v is v
pulled inside [lo, hi]: too small snaps to
lo, too big snaps to hi, inside passes
through. (A 60-wide paddle on our 400-wide canvas lives at
clamp 0. 340. x.)
min hi v handles "too big". What handles "too small"?max lo (min hi v).Determinism: the replay superpower
Why the fuss about pure? A pure function computes its result
from its arguments and does nothing else — no reading globals, no
Random, no peeking at the clock, no printing. So
step dt w is deterministic: same arguments, same new
world. Every time. On every machine. Feel what that buys a game:
- Replays cost nothing. Save the initial world and the inputs; re-running
steprebuilds the identical game. A replay file is kilobytes. - Multiplayer gets honest. Lockstep netcode ships only the inputs; both machines simulate the same worlds — because
stepcan't disagree with itself. - Saving is trivial. The world is one value. Store it, load it, done.
- Bugs get cornered. A crash on tick 4051 replays as a crash on tick 4051. Deterministic bugs are dead bugs walking.
A global mutable score would wreck all four: step's
output would depend on hidden state, and "same inputs" would no longer
promise "same game". That's the real reason this phase banned globals —
not ideology, engineering. Prove yours behaves:
A cloud scrolls forever across the sky. Write
scroll : float -> world -> world: move
x' = w.x +. w.vx *. dt; if x' >= 400.
wrap around to x' -. 400. (one wrap is plenty — callers
keep vx *. dt small); vx never changes.
The tests call your function twice with identical arguments
and demand identical worlds — the determinism check.
step_ball, but the wall teleports instead of bouncing.>=, so landing exactly on 400. wraps to 0.Checkpoint quiz
Loot collected
- The tick:
step : float -> world -> world, a pure function from photo to photo. dtconverts speeds to per-second, making motion frame-rate independent.Game.run ~init ~step ~draw— rules instep, pixels quarantined indraw.- Pure means deterministic: replays, lockstep multiplayer, trivial saves, cornered bugs.
clamp lo hi v = max lo (min hi v)— and never compare floats with=.
Everything is on the workbench: records, variants, lists, the tick. Next lesson we bolt them together into a real, playable snake.