Skip to content

2.3 Screen Buffering

The naive render approach — clear screen, redraw everything — works fine for most TUIs. But in two cases it flickers:

  1. Fast-updating UIs: a game running at 30+ fps, or a log tail with rapid output
  2. Slow connections: SSH over a high-latency link where every byte to stdout is expensive

The solution is double buffering: maintain two screen grids in memory. Only write to stdout the cells that actually changed between frames.

Frame N-1 Frame N (new state)
┌───┬───┬───┐ ┌───┬───┬───┐
│ H │ i │ │ │ H │ i │ │
│ │ │ ! │ diff │ │ │ * │ ← only cell (2,1) changed
│ │ │ │ ──────► │ │ │ │
└───┴───┴───┘ └───┴───┴───┘
stdout output:
\x1b[2;3H* ← move to (col=3, row=2), write "*"
(That's 8 bytes instead of clearing and rewriting the entire screen)
examples/module-2/screen-buffer.ts
export type Cell = {
char: string;
style: string; // the ANSI style prefix, e.g. "\x1b[32m"
};
const EMPTY_CELL: Cell = { char: ' ', style: '' };
export class ScreenBuffer {
private current: Cell[][];
private next: Cell[][];
private cols: number;
private rows: number;
constructor(cols: number, rows: number) {
this.cols = cols;
this.rows = rows;
this.current = this.makeGrid(cols, rows);
this.next = this.makeGrid(cols, rows);
}
private makeGrid(cols: number, rows: number): Cell[][] {
return Array.from({ length: rows }, () =>
Array.from({ length: cols }, () => ({ ...EMPTY_CELL }))
);
}
// Write a styled string to the next buffer at (col, row) — 0-indexed
write(col: number, row: number, text: string, style = '') {
const stripped = text.replace(/\x1b\[[0-9;]*m/g, ''); // remove inline ANSI
for (let i = 0; i < stripped.length; i++) {
if (col + i >= this.cols) break;
if (row >= this.rows) break;
this.next[row][col + i] = { char: stripped[i], style };
}
}
// Clear the next buffer (fill with spaces)
clear() {
for (let r = 0; r < this.rows; r++) {
for (let c = 0; c < this.cols; c++) {
this.next[r][c] = { ...EMPTY_CELL };
}
}
}
// Flush: compute diff, write only changed cells to stdout
flush() {
const out: string[] = [];
let lastStyle = '';
for (let r = 0; r < this.rows; r++) {
for (let c = 0; c < this.cols; c++) {
const curr = this.current[r][c];
const next = this.next[r][c];
// Skip if cell hasn't changed
if (curr.char === next.char && curr.style === next.style) continue;
// Position cursor
out.push(`\x1b[${r + 1};${c + 1}H`);
// Apply style if changed
if (next.style !== lastStyle) {
out.push('\x1b[0m'); // reset
if (next.style) out.push(next.style);
lastStyle = next.style;
}
out.push(next.char);
// Update current buffer
this.current[r][c] = { ...next };
}
}
// Final reset
if (lastStyle) out.push('\x1b[0m');
if (out.length > 0) {
process.stdout.write(out.join(''));
}
}
resize(cols: number, rows: number) {
this.cols = cols;
this.rows = rows;
this.current = this.makeGrid(cols, rows);
this.next = this.makeGrid(cols, rows);
}
}
import chalk from 'chalk';
import ansiEscapes from 'ansi-escapes';
import { ScreenBuffer } from './screen-buffer';
const buf = new ScreenBuffer(process.stdout.columns, process.stdout.rows);
function render(frame: number) {
buf.clear();
// Title
buf.write(0, 0, 'Buffered Render Demo', '\x1b[1;36m');
// Animated counter
buf.write(0, 2, `Frame: ${frame}`, '\x1b[33m');
// A bouncing ball
const x = Math.floor((Math.sin(frame * 0.1) + 1) * 20);
const y = Math.floor((Math.cos(frame * 0.07) + 1) * 8);
buf.write(x, y + 4, '', '\x1b[32m');
buf.flush();
}
process.stdout.write(ansiEscapes.enterAlternativeScreen);
process.stdout.write(ansiEscapes.cursorHide);
process.stdout.write(ansiEscapes.clearScreen);
let frame = 0;
const loop = setInterval(() => {
render(frame++);
}, 33); // ~30 fps
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding('utf8');
process.stdin.on('data', (key: string) => {
if (key === 'q' || key === '\x03') {
clearInterval(loop);
process.stdin.setRawMode(false);
process.stdout.write(ansiEscapes.cursorShow);
process.stdout.write(ansiEscapes.exitAlternativeScreen);
process.exit(0);
}
});
ScenarioNaive redrawBuffered
Form with keyboard input✅ fineunnecessary
Menu with selection✅ fineunnecessary
Dashboard updating 1/sec✅ fineoverkill
Game at 30fps❌ flicker✅ required
Log tail with rapid output⚠️ may flicker✅ recommended

For everything in Module 3 (UI components), naive redraw is fine. For Module 4 (Snake), we use the buffer.

You may have noticed that ScreenBuffer.write takes a plain style string like '\x1b[32m' instead of using chalk. This is because chalk wraps content, not positions — it bundles the start and reset codes together. The buffer needs raw start codes to track the current style state.

A practical helper:

import chalk, { ChalkInstance } from 'chalk';
// Extract the opening escape sequence from a chalk instance applied to a dummy string
function chalkToStyle(c: ChalkInstance): string {
const styled = c('x');
const match = styled.match(/^(\x1b\[[0-9;]*m)+/);
return match ? match[0] : '';
}
// Usage:
buf.write(5, 2, 'Hello', chalkToStyle(chalk.bold.green));

For game development in Module 4, we will use this helper throughout.