Skip to content
Ayotl

← How a game is made: Daily Challenge's Snake · Lesson 5/16

30 ticks a second, always

✕
1/8

The trouble with frames

The browser lets you know each time the screen is ready for a new frame with requestAnimationFrame. But not all screens run the same: many at 60 frames per second, others at 90 or 120, and a busy phone skips frames.

If the snake moved one cell per frame, on a 120 Hz phone it would go twice as fast as on a 60 Hz one. Worse: the server, which has no screen, wouldn't know how many frames there were.

Daily Challenge's solution: separate drawing from simulating. Draw whenever the screen wants; simulate always at 30 ticks per second.

leccion.js

$ node leccion.js

Press RUN (or Ctrl+Enter).

What to look at

Per frame: 60 versus 120 steps in the same second. Per tick: 30 on both. The game runs at the same speed; the 120 Hz screen just draws more often.

requestAnimationFrame (MDN) ↗

The whole lesson on one page

The trouble with frames

The browser lets you know each time the screen is ready for a new frame with requestAnimationFrame. But not all screens run the same: many at 60 frames per second, others at 90 or 120, and a busy phone skips frames.

If the snake moved one cell per frame, on a 120 Hz phone it would go twice as fast as on a 60 Hz one. Worse: the server, which has no screen, wouldn't know how many frames there were.

Daily Challenge's solution: separate drawing from simulating. Draw whenever the screen wants; simulate always at 30 ticks per second.

// One second of play on two different screens
for (const hz of [60, 120]) {
  const frame = 1000 / hz;
  let porCuadro = 0, porTick = 0, acc = 0;
  for (let t = 0; t < 1000 - 0.001; t += frame) {
    porCuadro++;                      // wrong: one step per frame
    acc += frame;                     // right: fixed steps of 1000/30 ms
    while (acc >= 1000 / 30) { porTick++; acc -= 1000 / 30; }
  }
  console.log(hz + " Hz -> per frame:", porCuadro, "| per tick:", porTick);
}

Per frame: 60 versus 120 steps in the same second. Per tick: 30 on both. The game runs at the same speed; the 120 Hz screen just draws more often.

The accumulator

This is the loop in shell.js (error handling removed):

const dt = Math.min(now - last, 250); last = now; acc += dt; while (acc >= TICK_MS) { tick(); acc -= TICK_MS; } render();

with TICK_MS = 1000 / 30, about 33.3 ms. On each frame the elapsed time is added to the accumulator and as many ticks as fit are spent. The rest is kept for the next frame. If a frame arrives late, that frame runs two or three ticks in a row; if it arrives early, none. In the long run, always 30 a second.

const TICK_MS = 1000 / 30;
let acc = 0, ticks = 0;

// milliseconds between frames: steady, one late and one very quick
const cuadros = [16.7, 16.7, 16.7, 70, 8, 16.7];
for (const dt of cuadros) {
  acc += dt;
  let aqui = 0;
  while (acc >= TICK_MS) { ticks++; aqui++; acc -= TICK_MS; }
  console.log("dt", String(dt).padStart(4), "-> ticks this frame:", aqui, " left:", acc.toFixed(1));
}
console.log("total:", ticks, "ticks in", cuadros.reduce((a, b) => a + b, 0).toFixed(1), "ms");

The late frame (70 ms) runs two ticks at once to catch up with the clock; the 8 ms one runs none. In the end there are 4 ticks in 145 ms, right on 30 a second.

The 250 ms cap and pausing

If you switch tabs, the browser stops calling requestAnimationFrame. When you come back, now - last might be five seconds: with no cap, the loop would run 150 ticks in one go and you'd find your snake crashed. So dt is clamped to 250 ms: at most 7 ticks at once.

And while paused (or during the countdown on resume) tick isn't called and the accumulator is emptied: if (pausa.quieto) { acc = 0; … return; }. Paused time doesn't exist for the game, and nothing is written to the game's log.

This is also what makes the game fair: the score comes from ticks, not clock seconds. The server doesn't need to know how fast your phone was.

const TICK_MS = 1000 / 30;
function ticksDeUnCuadro(dt, conTope) {
  let acc = conTope ? Math.min(dt, 250) : dt, n = 0;
  while (acc >= TICK_MS) { n++; acc -= TICK_MS; }
  return n;
}

const ausencia = 5000;   // five seconds in another tab
console.log("no cap:", ticksDeUnCuadro(ausencia, false), "ticks at once");
console.log("with cap:", ticksDeUnCuadro(ausencia, true), "ticks at once");

150 versus 7. With the cap, the game doesn't try to "make up" lost time: it just carries on.

The snake doesn't move every tick

30 ticks a second would be 30 cells a second: unplayable. Snake keeps another counter, this.interval = 8: it moves one tick out of every eight. In update:

this.tick++; if (this.tick % this.interval !== 0) return;

The other ticks aren't wasted: the controls are read, the bonus bug's countdown runs and hunger goes down. When it eats, the interval drops and the snake speeds up:

this.interval = Math.max(3, 8 - Math.floor(this.eaten / 4));

A speed built from integers and the tick counter, no clock at all: identical on the phone and on the server.

for (const eaten of [0, 4, 8, 12, 16, 20, 40]) {
  const interval = Math.max(3, 8 - Math.floor(eaten / 4));
  const casillas = (30 / interval).toFixed(2);
  console.log("fruit", String(eaten).padStart(2), "-> every", interval, "ticks =", casillas, "cells/s");
}

It starts at 3.75 cells a second and reaches the limit (every 3 ticks, 10 a second) at 20 fruits. After that it doesn't speed up: Math.max(3, …) stops it.