4.2 Sprites & Grid Rendering
Terminal games use a grid as their world: a 2D array of cells, each occupied by either an entity (snake body, wall, food) or empty space. Understanding how to map between grid coordinates and terminal coordinates is the foundation of all terminal game rendering.
Grid vs Screen Coordinates
Section titled “Grid vs Screen Coordinates”The terminal is a grid of characters. A game also has a grid — but they’re usually not the same scale. You have two choices:
1:1 mapping: each game cell = one terminal character. Maximum play area, but characters are taller than wide (roughly 1:2 aspect ratio). Objects appear squashed vertically.
2:1 mapping: each game cell = two terminal characters side by side. Compensates for the character aspect ratio. A ●● block looks roughly square. This is what most terminal games use.
// 2:1 mapping: convert game (col, row) to terminal (col, row)function toScreen(gameX: number, gameY: number, offsetCol: number, offsetRow: number) { return { col: offsetCol + gameX * 2, // × 2 for aspect ratio row: offsetRow + gameY, };}The World Grid
Section titled “The World Grid”Represent the game world as a typed 2D array:
export type CellType = 'empty' | 'wall' | 'food' | 'snake-head' | 'snake-body';
export type Cell = { type: CellType;};
export type Grid = Cell[][]; // [row][col]
export function createGrid(cols: number, rows: number): Grid { return Array.from({ length: rows }, () => Array.from({ length: cols }, () => ({ type: 'empty' as CellType })) );}
export function getCell(grid: Grid, x: number, y: number): Cell | null { if (y < 0 || y >= grid.length) return null; if (x < 0 || x >= grid[0].length) return null; return grid[y][x];}
export function setCell(grid: Grid, x: number, y: number, type: CellType) { if (y >= 0 && y < grid.length && x >= 0 && x < grid[0].length) { grid[y][x] = { type }; }}Rendering the Grid
Section titled “Rendering the Grid”import chalk from 'chalk';import { ScreenBuffer } from '../module-2/screen-buffer';import { type Grid, type CellType } from './grid';
// Visual representation of each cell type (2 chars wide for aspect ratio)const CELL_GLYPHS: Record<CellType, { chars: string; style: string }> = { 'empty': { chars: ' ', style: '' }, 'wall': { chars: '██', style: '\x1b[2;37m' }, 'food': { chars: '◆◆', style: '\x1b[1;31m' }, 'snake-head': { chars: '██', style: '\x1b[1;32m' }, 'snake-body': { chars: '██', style: '\x1b[32m' },};
export function renderGrid( buf: ScreenBuffer, grid: Grid, offsetCol: number, // 0-indexed terminal column for top-left corner offsetRow: number, // 0-indexed terminal row for top-left corner) { for (let y = 0; y < grid.length; y++) { for (let x = 0; x < grid[0].length; x++) { const cell = grid[y][x]; const { chars, style } = CELL_GLYPHS[cell.type]; buf.write(offsetCol + x * 2, offsetRow + y, chars, style); } }}Sprite Patterns
Section titled “Sprite Patterns”Some game objects are multi-cell. A “sprite” is just a pattern: a record of {dx, dy, char, style} offsets relative to an origin position.
export type Sprite = { dx: number; dy: number; chars: string; style: string;}[];
// Example: a 3×3 explosion spriteexport const EXPLOSION: Sprite = [ { dx: -1, dy: -1, chars: '**', style: '\x1b[1;33m' }, { dx: 1, dy: -1, chars: '**', style: '\x1b[1;33m' }, { dx: 0, dy: 0, chars: '##', style: '\x1b[1;31m' }, { dx: -1, dy: 1, chars: '**', style: '\x1b[1;33m' }, { dx: 1, dy: 1, chars: '**', style: '\x1b[1;33m' },];
export function renderSprite( buf: ScreenBuffer, sprite: Sprite, originCol: number, originRow: number,) { for (const { dx, dy, chars, style } of sprite) { buf.write(originCol + dx * 2, originRow + dy, chars, style); }}Building a Grid Demo
Section titled “Building a Grid Demo”import chalk from 'chalk';import ansiEscapes from 'ansi-escapes';import { ScreenBuffer } from '../module-2/screen-buffer';import { createGrid, setCell } from './grid';import { renderGrid } from './render-grid';import { parseKey } from '../module-2/01-keypress-parser';
const GRID_W = 20;const GRID_H = 15;const OFFSET_COL = 2;const OFFSET_ROW = 2;
const buf = new ScreenBuffer(process.stdout.columns, process.stdout.rows);const grid = createGrid(GRID_W, GRID_H);
// Walls around the borderfor (let x = 0; x < GRID_W; x++) { setCell(grid, x, 0, 'wall'); setCell(grid, x, GRID_H - 1, 'wall');}for (let y = 0; y < GRID_H; y++) { setCell(grid, 0, y, 'wall'); setCell(grid, GRID_W - 1, y, 'wall');}
// A snake and some foodsetCell(grid, 5, 5, 'snake-head');setCell(grid, 4, 5, 'snake-body');setCell(grid, 3, 5, 'snake-body');setCell(grid, 10, 8, 'food');setCell(grid, 15, 3, 'food');
function render() { buf.clear();
// Title buf.write(0, 0, 'Grid Demo — press q to quit', '\x1b[1;36m');
renderGrid(buf, grid, OFFSET_COL, OFFSET_ROW);
// Legend const legendCol = OFFSET_COL + GRID_W * 2 + 2; buf.write(legendCol, OFFSET_ROW, '██ Wall', '\x1b[2;37m'); buf.write(legendCol, OFFSET_ROW + 1, '██ Snake Head', '\x1b[1;32m'); buf.write(legendCol, OFFSET_ROW + 2, '██ Snake Body', '\x1b[32m'); buf.write(legendCol, OFFSET_ROW + 3, '◆◆ Food', '\x1b[1;31m');
buf.flush();}
function cleanup() { 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();});
render();The 2:1 Trick and Aspect Ratio
Section titled “The 2:1 Trick and Aspect Ratio”Why does the 2:1 mapping matter? Monospace terminal fonts are approximately twice as tall as wide. A single character cell is roughly 8px wide × 16px tall. That means a 10×10 grid rendered at 1:1 would look like a 10×20 rectangle on screen — tall and thin.
By rendering each game cell as two terminal characters (██ instead of █), we make each game cell approximately square visually. The 2:1 ratio compensates exactly for the character aspect ratio.
This is why most terminal games and box-drawing examples look proportional when you see them in a terminal, even though they’re made of text characters.