The Tick
A game is a function of time, and time arrives as messages. One line requests it:
Time.every (Time.millis 16) (\_ -> Tick)
Sixteen milliseconds is one frame at 60 fps. This chapter is about what that line really does, because a game clock has to survive three enemies: displays that do not run at 60 Hz, devices that drop frames, and browser tabs that stop rendering entirely. Mar’s runtime has opinions about all three, and knowing them saves you from the classic mistakes.
Fast timers ride the display
Any Time.every interval of 20 milliseconds or less is not a timer in the setTimeout sense. The runtime attaches it to the display clock: requestAnimationFrame on the web, CADisplayLink on iOS. Your tick fires in lockstep with real frames, which is the only place a game tick is useful. (Slower intervals, like the one-second morph in the Touch-me-When example, run as ordinary timers.)
You can subscribe to both kinds at once: a 16 ms tick for the simulation and a one-second timer for a clock display coexist fine in one Sub.batch.
The game clock is not the display clock
Here is the scenario that breaks naive game loops. Your game does “one subscription tick = one step of the world” and a player opens it on a 30 Hz screen, or their phone throttles, or the browser hiccups for 200 ms. If ticks only fire with rendered frames, and each tick advances the world one step, the whole game runs at half speed: slow-motion music, floaty jumps, easy bosses. Speed as experienced by the player would depend on their hardware.
Mar’s runtime decouples the two clocks. Wall-clock time is accumulated, and when a display frame finally arrives, the runtime delivers as many pending ticks as have genuinely elapsed, up to a ceiling of four per frame. Each burst renders once. The consequences, measured on the real runtimes:
- On a 60 Hz display, one tick per frame, exactly as naive.
- On a 30 Hz display, about two ticks per frame: the game runs at full speed with fewer rendered frames.
- Under heavy throttling, up to four ticks per frame; beyond that the game slows rather than teleporting.
- After a long stall (say, five seconds), you do not get three hundred catch-up ticks and an enemy horde standing on your face. The ceiling caps the burst; the world resumes near where it paused.
What this means for you as a designer: a tick is a unit of game time, not of rendering. Never scale movement by measured elapsed milliseconds, and never count rendered frames. Write “the shot moves 5 pixels per tick” and let the runtime worry about how ticks map to frames. All the example games are written this way, and it is why the racer feels identical on a MacBook and a throttled phone.
Hidden tabs pause, by design
When a browser tab is hidden, the display clock stops, so your subscription tick stops, so your game pauses: physics, spawns, difficulty ramps, everything. On return, the catch-up ceiling prevents a fast-forward. For almost every game this is exactly right, and you get it without writing a pause system.
Two refinements worth knowing:
- Anything driven by the same tick pauses with it. This is the argument for driving music from the game world (chapter 10): a soundtrack played per-beat by
updatestops when the game stops, with no “music kept playing over the pause screen” bug. - A real pause feature (the player pressed Escape) is still yours to build, and it is two lines: a
Pausedphase whosesubscriptionsdrop the tick.
subscriptions model =
case model.phase of
Playing -> Sub.batch [ tick, keys ]
Paused -> Keyboard.onKeyDown KeyDown -- only the unpause key
_ -> Sub.none
Beats: the musician’s tick
Music runs on a grid too. At 120 BPM a beat is 500 ms, which is not a multiple of 16 ms, so do not count ticks and hope. Keep a millisecond accumulator in the model and step it by 16 per tick:
stepBeat : Model -> Model
stepBeat model =
let
t = model.msT + 16
in
if t >= 500 then
onBeat { model | msT = t - 500, beat = model.beat + 1 }
else
{ model | msT = t }
onBeat is where a rhythm game spawns its obstacles and where world-driven music plays its next groove step. Because the accumulator lives in the Model, the beat grid pauses, rewinds, and replays with everything else.
Determinism, guarded
One habit completes the picture: the tick handler ignores the timestamp (\_ -> Tick). The wall-clock time is real-world data, different on every run; let it into your simulation and replays die. The idiom throughout this book is that time advances the world by messages, never by measurement. The one legitimate use of Time.now in a game is outside the simulation: seeding a run, stamping a high score.
Next: what a frame actually is.