Skip to content

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.

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,
};
}

Represent the game world as a typed 2D array:

examples/module-4/grid.ts
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 };
}
}
examples/module-4/render-grid.ts
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);
}
}
}

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 sprite
export 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);
}
}
examples/module-4/02-sprites.ts
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 border
for (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 food
setCell(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();

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.