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.
The Classic Game Loop
Section titled “The Classic Game Loop”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
Fixed Timestep vs Variable Timestep
Section titled “Fixed Timestep vs Variable Timestep”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
setIntervalgives us fixed ticks for free - Reproducible state makes debugging easier
Node.js Game Loop
Section titled “Node.js Game Loop”setInterval is our tick:
const TARGET_FPS = 10; // Snake doesn't need 60fpsconst 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.
Decoupling Input From Update
Section titled “Decoupling Input From Update”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.
Frame Skipping and Slow Machines
Section titled “Frame Skipping and Slow Machines”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.
A Minimal Game Loop Demo
Section titled “A Minimal Game Loop Demo”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); // 20fpsRun 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.
Frame Rate and Terminal Games
Section titled “Frame Rate and Terminal Games”Why not 60fps? A few reasons:
-
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.
-
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.
-
Node.js timing:
setIntervalin 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.