From 6e2fc5a05aa7dcb40a06bd889915a7f24ad49410 Mon Sep 17 00:00:00 2001 From: edo Date: Thu, 30 Apr 2026 22:24:13 +0000 Subject: [PATCH] Add terminal Snake game in Node.js Zero-dependency, fully playable Snake with ANSI colors, box-drawn border, score/high-score tracking, and title screen. Co-Authored-By: Claude Sonnet 4.6 --- snake.js | 197 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 snake.js diff --git a/snake.js b/snake.js new file mode 100644 index 0000000..7deb98b --- /dev/null +++ b/snake.js @@ -0,0 +1,197 @@ +#!/usr/bin/env node +'use strict'; + +const readline = require('readline'); + +// --- Config --- +const W = 40, H = 20; +const TICK = 100; // ms per frame + +// --- ANSI helpers --- +const ESC = '\x1b['; +const cls = () => process.stdout.write('\x1bc'); +const hide = () => process.stdout.write(ESC + '?25l'); +const show = () => process.stdout.write(ESC + '?25h'); +const move = (x, y) => process.stdout.write(ESC + `${y};${x}H`); +const color = (code, s) => `\x1b[${code}m${s}\x1b[0m`; +const green = s => color('32;1', s); +const yellow = s => color('33;1', s); +const red = s => color('31;1', s); +const cyan = s => color('36;1', s); +const white = s => color('37;1', s); +const dim = s => color('2', s); + +// --- State --- +let snake, dir, nextDir, food, score, highScore = 0, running, timer; + +function randPos() { + return { x: 1 + Math.floor(Math.random() * (W - 2)), y: 1 + Math.floor(Math.random() * (H - 2)) }; +} + +function spawnFood() { + let pos; + do { pos = randPos(); } + while (snake.some(s => s.x === pos.x && s.y === pos.y)); + food = pos; +} + +function init() { + const mid = { x: Math.floor(W / 2), y: Math.floor(H / 2) }; + snake = [mid, { x: mid.x - 1, y: mid.y }, { x: mid.x - 2, y: mid.y }]; + dir = { x: 1, y: 0 }; + nextDir = { x: 1, y: 0 }; + score = 0; + spawnFood(); + running = true; +} + +function drawBorder() { + const top = cyan('╔' + '═'.repeat(W - 2) + '╗'); + const bottom = cyan('╚' + '═'.repeat(W - 2) + '╝'); + move(1, 1); process.stdout.write(top); + move(1, H); process.stdout.write(bottom); + for (let row = 2; row < H; row++) { + move(1, row); process.stdout.write(cyan('║')); + move(W, row); process.stdout.write(cyan('║')); + } +} + +function drawStatus() { + move(1, H + 1); + process.stdout.write( + white(' Score: ') + yellow(String(score).padEnd(6)) + + white(' Best: ') + yellow(String(highScore).padEnd(6)) + + dim(' [←↑→↓] move [q] quit') + ); +} + +function drawGame() { + // Clear interior + for (let row = 2; row < H; row++) { + move(2, row); + process.stdout.write(' '.repeat(W - 2)); + } + + // Food + move(food.x, food.y); + process.stdout.write(red('◆')); + + // Snake + snake.forEach((seg, i) => { + move(seg.x, seg.y); + process.stdout.write(i === 0 ? green('█') : green('▓')); + }); + + drawStatus(); +} + +function tick() { + dir = nextDir; + const head = { x: snake[0].x + dir.x, y: snake[0].y + dir.y }; + + // Wall collision + if (head.x <= 0 || head.x >= W || head.y <= 0 || head.y >= H) { + return gameOver(); + } + // Self collision + if (snake.some(s => s.x === head.x && s.y === head.y)) { + return gameOver(); + } + + snake.unshift(head); + + if (head.x === food.x && head.y === food.y) { + score++; + if (score > highScore) highScore = score; + spawnFood(); + } else { + snake.pop(); + } + + drawGame(); +} + +function gameOver() { + running = false; + clearInterval(timer); + + const msg = red(' GAME OVER '); + const sub = ` Score: ${yellow(String(score))} `; + const reLine = ` Press ${green('r')} to restart or ${red('q')} to quit `; + + const cx = Math.floor(W / 2) - 12; + const cy = Math.floor(H / 2) - 1; + move(cx, cy); process.stdout.write('┌' + '─'.repeat(28) + '┐'); + move(cx, cy + 1); process.stdout.write('│' + msg.padEnd(28 + msg.length - 13) + '│'); + move(cx, cy + 2); process.stdout.write('│' + sub.padEnd(28 + sub.length - 14) + '│'); + move(cx, cy + 3); process.stdout.write('│' + reLine.padEnd(28 + reLine.length - 27) + '│'); + move(cx, cy + 4); process.stdout.write('└' + '─'.repeat(28) + '┘'); +} + +function startGame() { + cls(); + hide(); + init(); + drawBorder(); + drawGame(); + timer = setInterval(tick, TICK); +} + +// --- Input --- +readline.emitKeypressEvents(process.stdin); +if (process.stdin.isTTY) process.stdin.setRawMode(true); + +process.stdin.on('keypress', (_, key) => { + if (!key) return; + + if (key.name === 'q' || (key.ctrl && key.name === 'c')) { + clearInterval(timer); + show(); + cls(); + move(1, 1); + console.log(white('Thanks for playing! Final score: ') + yellow(String(score))); + process.exit(0); + } + + if (!running) { + if (key.name === 'r') { startGame(); } + return; + } + + const moves = { + up: { x: 0, y: -1 }, + down: { x: 0, y: 1 }, + left: { x: -1, y: 0 }, + right: { x: 1, y: 0 }, + }; + + const next = moves[key.name]; + if (!next) return; + + // Prevent 180° reversal + if (next.x === -dir.x && next.y === -dir.y) return; + nextDir = next; +}); + +// --- Title screen --- +cls(); +move(1, Math.floor(H / 2) - 3); +const logo = [ + ' ███████╗███╗ ██╗ █████╗ ██╗ ██╗███████╗', + ' ██╔════╝████╗ ██║██╔══██╗██║ ██╔╝██╔════╝', + ' ███████╗██╔██╗██║███████║█████╔╝ █████╗ ', + ' ╚════██║██║╚████║██╔══██║██╔═██╗ ██╔══╝ ', + ' ███████║██║ ╚███║██║ ██║██║ ██╗███████╗', + ' ╚══════╝╚═╝ ╚══╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝', +]; +logo.forEach((line, i) => { move(1, Math.floor(H / 2) - 3 + i); process.stdout.write(green(line)); }); +move(1, Math.floor(H / 2) + 4); +process.stdout.write(white(' Use ') + cyan('arrow keys') + white(' to move · ') + yellow('eat ◆ to grow') + white(' · ') + red('q') + white(' to quit')); +move(1, Math.floor(H / 2) + 6); +process.stdout.write(white(' Press ') + green('any arrow key') + white(' to start...')); + +// Start on first keypress +process.stdin.once('keypress', (_, key) => { + if (key && (key.ctrl && key.name === 'c')) process.exit(0); + startGame(); +});