4.4 Project: Snake
import { Aside } from ‘@astrojs/starlight/components’;
The Module 4 capstone: a complete Snake game. It combines everything from this module — game loop, grid rendering, screen buffering, collision detection — plus a title screen, game-over screen, and increasing difficulty.
Game Design
Section titled “Game Design”- Grid: 30×20 cells, rendered 2:1 (60×20 terminal characters for the play area)
- Starting snake: 3 segments, moving right
- Food: 1 pellet at a time, spawned randomly
- Scoring: +10 per food eaten
- Speed: starts at 8fps (125ms/tick), increases every 50 points
- Game over: hit wall or self
- Restart: press
ron the game-over screen
Project Files
Section titled “Project Files”examples/module-4/snake/├── types.ts — shared types├── state.ts — all game logic (pure functions)├── render.ts — all rendering└── main.ts — entry pointtypes.ts
Section titled “types.ts”export type Vec2 = { x: number; y: number };
export type Direction = Vec2;
export const DIR = { UP: { x: 0, y: -1 }, DOWN: { x: 0, y: 1 }, LEFT: { x: -1, y: 0 }, RIGHT: { x: 1, y: 0 },} as const satisfies Record<string, Direction>;
export type GameStatus = 'title' | 'playing' | 'paused' | 'game-over';
export type GameState = { status: GameStatus; snake: { body: Vec2[]; direction: Direction }; food: Vec2; score: number; highScore: number; pendingDir: Direction | null; frame: number;};state.ts
Section titled “state.ts”import { type Vec2, type Direction, type GameState, DIR } from './types';
export const GRID_W = 30;export const GRID_H = 20;
function spawnFood(snake: Vec2[]): Vec2 { const occupied = new Set(snake.map(s => `${s.x},${s.y}`)); const empty: Vec2[] = []; for (let y = 1; y < GRID_H - 1; y++) for (let x = 1; x < GRID_W - 1; x++) if (!occupied.has(`${x},${y}`)) empty.push({ x, y }); if (empty.length === 0) return { x: 1, y: 1 }; return empty[Math.floor(Math.random() * empty.length)];}
function isOpposite(a: Direction, b: Direction): boolean { return a.x === -b.x && a.y === -b.y;}
export function initialState(highScore = 0): GameState { const snake = [ { x: 6, y: 10 }, { x: 5, y: 10 }, { x: 4, y: 10 }, ]; return { status: 'title', snake: { body: snake, direction: DIR.RIGHT }, food: spawnFood(snake), score: 0, highScore, pendingDir: null, frame: 0, };}
export function tickSpeed(score: number): number { // Start at 125ms, speed up every 50 points, minimum 50ms const level = Math.floor(score / 50); return Math.max(50, 125 - level * 10);}
export function update(state: GameState): GameState { if (state.status !== 'playing') return state;
const dir = (state.pendingDir && !isOpposite(state.pendingDir, state.snake.direction)) ? state.pendingDir : state.snake.direction;
const head = state.snake.body[0]; const newHead = { x: head.x + dir.x, y: head.y + dir.y };
// Wall collision if (newHead.x <= 0 || newHead.x >= GRID_W - 1 || newHead.y <= 0 || newHead.y >= GRID_H - 1) { return { ...state, status: 'game-over', highScore: Math.max(state.score, state.highScore), pendingDir: null }; }
// Self collision (skip head itself = index 0) const body = state.snake.body; for (let i = 1; i < body.length; i++) { if (newHead.x === body[i].x && newHead.y === body[i].y) { return { ...state, status: 'game-over', highScore: Math.max(state.score, state.highScore), pendingDir: null }; } }
// Food collision const ateFood = newHead.x === state.food.x && newHead.y === state.food.y; const newBody = ateFood ? [newHead, ...body] // grow: keep tail : [newHead, ...body.slice(0, -1)]; // move: drop tail
const newScore = ateFood ? state.score + 10 : state.score;
return { ...state, snake: { body: newBody, direction: dir }, food: ateFood ? spawnFood(newBody) : state.food, score: newScore, highScore: Math.max(newScore, state.highScore), pendingDir: null, frame: state.frame + 1, };}render.ts
Section titled “render.ts”import chalk from 'chalk';import { ScreenBuffer } from '../../module-2/screen-buffer';import { type GameState, type GameStatus } from './types';import { GRID_W, GRID_H } from './state';
// Terminal offset for the game gridconst GRID_COL = 2; // 0-indexedconst GRID_ROW = 2; // 0-indexedconst CELL = 2; // terminal chars per grid cell
// Style shortcuts (raw ANSI for ScreenBuffer)const S = { wall: '\x1b[2;37m', snakeHead: '\x1b[1;32m', snakeBody: '\x1b[32m', food: '\x1b[1;31m', title: '\x1b[1;36m', score: '\x1b[1;33m', dim: '\x1b[2m', bold: '\x1b[1m', red: '\x1b[1;31m', green: '\x1b[1;32m', cyan: '\x1b[1;36m',};
function gridToScreen(x: number, y: number) { return { col: GRID_COL + x * CELL, row: GRID_ROW + y };}
export function render(buf: ScreenBuffer, state: GameState) { buf.clear();
if (state.status === 'title') { renderTitle(buf); return buf.flush(); }
renderGame(buf, state);
if (state.status === 'game-over') { renderGameOver(buf, state); } else if (state.status === 'paused') { renderPaused(buf); }
buf.flush();}
function renderGame(buf: ScreenBuffer, state: GameState) { const cols = process.stdout.columns;
// HUD buf.write(GRID_COL, 0, `Score: ${state.score}`, S.score); buf.write(GRID_COL + 16, 0, `High: ${state.highScore}`, S.dim); buf.write(GRID_COL + 34, 0, 'p:pause q:quit', S.dim);
// Walls (border) for (let x = 0; x < GRID_W; x++) { const top = gridToScreen(x, 0); const bot = gridToScreen(x, GRID_H - 1); buf.write(top.col, top.row, '██', S.wall); buf.write(bot.col, bot.row, '██', S.wall); } for (let y = 1; y < GRID_H - 1; y++) { const left = gridToScreen(0, y); const right = gridToScreen(GRID_W - 1, y); buf.write(left.col, left.row, '██', S.wall); buf.write(right.col, right.row, '██', S.wall); }
// Food const fs = gridToScreen(state.food.x, state.food.y); buf.write(fs.col, fs.row, '◆◆', S.food);
// Snake body (tail to neck, so head renders on top) for (let i = state.snake.body.length - 1; i >= 1; i--) { const seg = gridToScreen(state.snake.body[i].x, state.snake.body[i].y); buf.write(seg.col, seg.row, '██', S.snakeBody); }
// Snake head const head = gridToScreen(state.snake.body[0].x, state.snake.body[0].y); buf.write(head.col, head.row, '██', S.snakeHead);
// Speed indicator const level = Math.floor(state.score / 50) + 1; buf.write(GRID_COL, GRID_ROW + GRID_H + 1, `Level ${level} (${state.snake.body.length} segments)`, S.dim);}
function renderTitle(buf: ScreenBuffer) { const cols = process.stdout.columns; const rows = process.stdout.rows; const cx = Math.floor(cols / 2); const cy = Math.floor(rows / 2);
const title = ' SNAKE '; buf.write(cx - 10, cy - 4, '╔══════════════════╗', S.cyan); buf.write(cx - 10, cy - 3, '║ ║', S.cyan); buf.write(cx - 10, cy - 2, `║${title.padStart(14).padEnd(18)}║`, S.cyan); buf.write(cx - 10, cy - 1, '║ ║', S.cyan); buf.write(cx - 10, cy, '╠══════════════════╣', S.cyan); buf.write(cx - 10, cy + 1, '║ Press ENTER ║', S.cyan); buf.write(cx - 10, cy + 2, '║ to start ║', S.cyan); buf.write(cx - 10, cy + 3, '║ q to quit ║', S.cyan); buf.write(cx - 10, cy + 4, '╚══════════════════╝', S.cyan);
buf.write(cx - 5, cy - 2, title, S.snakeHead);}
function renderGameOver(buf: ScreenBuffer, state: GameState) { const cols = process.stdout.columns; const rows = process.stdout.rows; const cx = Math.floor(cols / 2); const cy = Math.floor(rows / 2);
buf.write(cx - 12, cy - 3, '╔══════════════════════╗', S.red); buf.write(cx - 12, cy - 2, '║ GAME OVER ║', S.red); buf.write(cx - 12, cy - 1, '╠══════════════════════╣', S.red); buf.write(cx - 12, cy, `║ Score: ${String(state.score).padEnd(13)}║`, S.red); buf.write(cx - 12, cy + 1, `║ Best: ${String(state.highScore).padEnd(13)}║`, S.score); buf.write(cx - 12, cy + 2, '╠══════════════════════╣', S.red); buf.write(cx - 12, cy + 3, '║ r: restart q: quit ║', S.red); buf.write(cx - 12, cy + 4, '╚══════════════════════╝', S.red);}
function renderPaused(buf: ScreenBuffer) { const cx = Math.floor(process.stdout.columns / 2); const cy = Math.floor(process.stdout.rows / 2); buf.write(cx - 9, cy, ' ── PAUSED ── ', S.bold); buf.write(cx - 9, cy + 1, ' p: resume ', S.dim);}main.ts
Section titled “main.ts”import ansiEscapes from 'ansi-escapes';import { ScreenBuffer } from '../../module-2/screen-buffer';import { initialState, update, tickSpeed, GRID_W, GRID_H } from './state';import { render } from './render';import { parseKey } from '../../module-2/01-keypress-parser';import { DIR, type GameState } from './types';
let state: GameState = initialState();let loop: ReturnType<typeof setInterval> | null = null;const buf = new ScreenBuffer(process.stdout.columns, process.stdout.rows);
function startLoop() { if (loop) clearInterval(loop); const speed = tickSpeed(state.score); loop = setInterval(() => { state = update(state);
// Reschedule if speed changed (snake ate food) const newSpeed = tickSpeed(state.score); if (newSpeed !== speed) startLoop();
render(buf, state); }, speed);}
function onKey(raw: string) { const key = parseKey(raw);
switch (state.status) { case 'title': if (key.name === 'enter') { state = { ...state, status: 'playing' }; startLoop(); render(buf, state); } if (key.name === 'q') cleanup(); break;
case 'playing': if (key.name === 'up' && state.snake.direction !== DIR.DOWN) state = { ...state, pendingDir: DIR.UP }; if (key.name === 'down' && state.snake.direction !== DIR.UP) state = { ...state, pendingDir: DIR.DOWN }; if (key.name === 'left' && state.snake.direction !== DIR.RIGHT) state = { ...state, pendingDir: DIR.LEFT }; if (key.name === 'right' && state.snake.direction !== DIR.LEFT) state = { ...state, pendingDir: DIR.RIGHT }; if (key.name === 'p') { state = { ...state, status: 'paused' }; if (loop) clearInterval(loop); render(buf, state); } if (key.name === 'q') cleanup(); break;
case 'paused': if (key.name === 'p') { state = { ...state, status: 'playing' }; startLoop(); } if (key.name === 'q') cleanup(); break;
case 'game-over': if (key.name === 'r') { state = initialState(state.highScore); state = { ...state, status: 'playing' }; startLoop(); render(buf, state); } if (key.name === 'q') cleanup(); break; }}
function cleanup() { if (loop) 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.on('SIGTERM', cleanup);process.stdout.on('resize', () => { buf.resize(process.stdout.columns, process.stdout.rows); render(buf, state);});
process.stdin.setRawMode(true);process.stdin.resume();process.stdin.setEncoding('utf8');process.stdin.on('data', onKey);
render(buf, state);Run it:
cd examples && npx tsx module-4/snake/main.tsExtending the Game
Section titled “Extending the Game”Now that you have a working game, here are good exercises:
Easy:
- Add a speed/level display that shows which level you’re on
- Make the food blink (alternate between
◆◆andevery few frames) - Add a walls mode where hitting a wall teleports to the opposite side
Medium:
- Add multiple food items (3 at once)
- Implement obstacles that spawn as the score increases
- Add a high-score leaderboard saved to disk
Hard:
- Add a simple AI opponent that also plays Snake on the same grid
- Implement Tetris (the grid/buffer/loop skills transfer directly)
- Add Sixel or Kitty protocol image rendering for actual pixel graphics
Module 4 and the full course basics are complete. You can now build any terminal game or app from scratch in TypeScript, understanding every layer from raw PTY to pixel (character) rendering.