Skip to content

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.

  • 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 r on the game-over screen
examples/module-4/snake/
├── types.ts — shared types
├── state.ts — all game logic (pure functions)
├── render.ts — all rendering
└── main.ts — entry point
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;
};
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,
};
}
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 grid
const GRID_COL = 2; // 0-indexed
const GRID_ROW = 2; // 0-indexed
const 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);
}
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:

Terminal window
cd examples && npx tsx module-4/snake/main.ts

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 ◆◆ and every 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.