2.3 Screen Buffering
The naive render approach — clear screen, redraw everything — works fine for most TUIs. But in two cases it flickers:
- Fast-updating UIs: a game running at 30+ fps, or a log tail with rapid output
- 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.
How Double Buffering Works
Section titled “How Double Buffering Works”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)A Screen Buffer Implementation
Section titled “A Screen Buffer Implementation”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); }}Using the Buffer
Section titled “Using the Buffer”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); }});When to Use Buffering
Section titled “When to Use Buffering”| Scenario | Naive redraw | Buffered |
|---|---|---|
| Form with keyboard input | ✅ fine | unnecessary |
| Menu with selection | ✅ fine | unnecessary |
| Dashboard updating 1/sec | ✅ fine | overkill |
| 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.
The chalk + Buffer Gap
Section titled “The chalk + Buffer Gap”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 stringfunction 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.