Skip to content

4.1 The Game Loop Pattern

A TUI app re-renders when the user presses a key. A game re-renders continuously — at a fixed rate — regardless of input. This distinction drives everything in game architecture.

Every game engine uses some variation of this:

while (running) {
processInput()
update(deltaTime)
render()
sleep(targetFrameTime - elapsed)
}
  • processInput — read all pending input events since the last frame
  • update — advance the game world by one timestep (move entities, check collisions, apply gravity)
  • render — draw the current world state
  • sleep — wait until it’s time for the next frame

There are two schools of thought:

Variable timestep: update(deltaTime) where deltaTime is the actual elapsed time since the last frame. Physics and movement scale with real time, so the game runs at the same speed regardless of frame rate.

Fixed timestep: update() is called a fixed number of times per second. Movement is expressed in “units per tick” rather than “units per second”. Simpler to reason about, perfectly reproducible.

For terminal games, fixed timestep is almost always the right choice:

  • Games are simple enough that exact timing math isn’t needed
  • Node.js setInterval gives us fixed ticks for free
  • Reproducible state makes debugging easier

setInterval is our tick:

const TARGET_FPS = 10; // Snake doesn't need 60fps
const TICK_MS = 1000 / TARGET_FPS;
let gameRunning = true;
const loop = setInterval(() => {
if (!gameRunning) {
clearInterval(loop);
return;
}
update();
render();
}, TICK_MS);

For terminal games, 10–30 fps is more than enough. setInterval is not perfectly precise (Node.js is single-threaded), but the jitter is invisible at these frame rates.

In a game, input should queue intentions, not mutate state directly. This is because the game loop drives state changes on its own timer — input just influences the next tick:

// Input state — set by keyboard events, consumed by update()
let pendingDirection: Direction | null = null;
process.stdin.on('data', (raw: string) => {
const key = parseKey(raw);
if (key.name === 'up') pendingDirection = 'up';
if (key.name === 'down') pendingDirection = 'down';
if (key.name === 'left') pendingDirection = 'left';
if (key.name === 'right') pendingDirection = 'right';
});
function update() {
if (pendingDirection !== null) {
gameState.snake.direction = pendingDirection;
pendingDirection = null; // consumed
}
// ... rest of update
}

Why? Because at 10fps, a tick happens every 100ms. A fast typist can press two keys between ticks. If input directly mutated direction, the second keypress would overwrite the first before update() could process it. The “last wins” input model here is intentional: we only care about the direction when the snake moves, not when the key was pressed.

On a very slow machine or under load, setInterval may call your callback late. If update() + render() takes longer than TICK_MS, the next tick is already past due.

For simple terminal games this is fine — just let it run a bit slow. The classic fix for physics-heavy games (running update multiple times to “catch up”) is overkill here.

examples/module-4/01-game-loop.ts
import chalk from 'chalk';
import ansiEscapes from 'ansi-escapes';
import { parseKey } from '../module-2/01-keypress-parser';
import { ScreenBuffer } from '../module-2/screen-buffer';
type Vec2 = { x: number; y: number };
type State = {
ball: Vec2;
velocity: Vec2;
frame: number;
};
const state: State = {
ball: { x: 10, y: 5 },
velocity: { x: 1, y: 1 },
frame: 0,
};
const buf = new ScreenBuffer(process.stdout.columns, process.stdout.rows);
function update() {
const cols = process.stdout.columns;
const rows = process.stdout.rows;
// Move
state.ball.x += state.velocity.x;
state.ball.y += state.velocity.y;
// Bounce off walls
if (state.ball.x <= 0 || state.ball.x >= cols - 1) state.velocity.x *= -1;
if (state.ball.y <= 0 || state.ball.y >= rows - 3) state.velocity.y *= -1;
state.frame++;
}
function render() {
const cols = process.stdout.columns;
buf.clear();
buf.write(0, 0, `Frame: ${state.frame} Ball: (${state.ball.x}, ${state.ball.y})`, '\x1b[2m');
buf.write(0, 1, ''.repeat(cols), '\x1b[2m');
buf.write(state.ball.x, state.ball.y, '', '\x1b[1;32m');
buf.write(0, process.stdout.rows - 1, 'q: quit', '\x1b[2m');
buf.flush();
}
function cleanup() {
clearInterval(loop);
process.stdin.setRawMode(false);
process.stdout.write(ansiEscapes.cursorShow);
process.stdout.write(ansiEscapes.exitAlternativeScreen);
process.exit(0);
}
process.stdout.write(ansiEscapes.enterAlternativeScreen);
process.stdout.write(ansiEscapes.cursorHide);
process.stdout.write(ansiEscapes.clearScreen);
process.on('SIGINT', cleanup);
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding('utf8');
process.stdin.on('data', (raw: string) => {
const key = parseKey(raw);
if (key.name === 'q') cleanup();
});
process.stdout.on('resize', () => {
buf.resize(process.stdout.columns, process.stdout.rows);
});
const loop = setInterval(() => {
update();
render();
}, 50); // 20fps

Run it and watch the ball bounce. Notice: no flicker, because ScreenBuffer only writes changed cells. Change the fps to 2 (500ms tick) to see the loop in slow motion.

Why not 60fps? A few reasons:

  1. Terminal throughput: At 60fps you are writing to stdout 60 times per second. For a 200×50 terminal, even with diff rendering, you may send 50-200KB/s. Over SSH this saturates low-bandwidth connections.

  2. Game feel: Classic terminal games (roguelikes, Snake, Tetris) feel natural at 10–30fps. Higher fps doesn’t improve the experience because the game logic doesn’t benefit from it.

  3. Node.js timing: setInterval in Node.js has ~1ms resolution on Linux/Mac. At 60fps you need 16ms accuracy — which is fine. But at 120fps (8ms) you start seeing jitter.

For the Snake game in lesson 4.4, we will use 8fps as the base speed, increasing as the score grows.