├── .gitignore ├── .replit ├── LICENSE ├── README.md ├── index.js ├── package-lock.json ├── package.json ├── play.js ├── snek.gif └── src ├── Game.js ├── UserInterface.js └── constants.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.replit: -------------------------------------------------------------------------------- 1 | language = "nodejs" 2 | run = "npm i && npm run play" 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Tania Rascia 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🐍 Snek.js 2 | 3 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) [![snekjs on NPM](https://img.shields.io/npm/v/snekjs.svg?color=green&label=snekjs)](https://www.npmjs.com/package/snekjs) 4 | 5 | [![Run on Repl.it](https://repl.it/badge/github/taniarascia/snek)](https://repl.it/github/taniarascia/snek) 6 | 7 | A terminal-based Snake implementation written in JavaScript (Node.js). 8 | 9 | ### [Read the tutorial](https://www.taniarascia.com/snake-game-in-javascript/) 10 | 11 | ![snek.gif](https://raw.githubusercontent.com/taniarascia/snek/master/snek.gif) 12 | 13 | ## Instructions 14 | 15 | Use the arrow keys (`↑`, `↓`, `←`, `→`) or `W` `A` `S` `D` to navigate the snake up, down, left, or right. Eat the red dot to gain points. If the snake collides with the wall or its own tail, it's game over. Press `ENTER` to restart, and `Q`, `ESCAPE` or `CTRL` + `C` to quit the game. 16 | 17 | ## Installation 18 | 19 | ### Run without installing 20 | 21 | The easiest way to play the game is to just run it in the terminal without installing anything! 22 | 23 | ```bash 24 | npx taniarascia/snek 25 | ``` 26 | 27 | ### Clone from repository 28 | 29 | ```bash 30 | git clone https://github.com/taniarascia/snek 31 | cd snek 32 | 33 | # install and run via npm or yarn 34 | npm i && npm run play 35 | ``` 36 | 37 | ### npm module 38 | 39 | Add the `snekjs` module. 40 | 41 | ```bash 42 | npm i snekjs 43 | ``` 44 | 45 | Create the game. 46 | 47 | ```js 48 | // index.js 49 | const { UserInterface, Game } = require('snekjs') 50 | const game = new Game(new UserInterface()) 51 | 52 | // Begin game 53 | game.start() 54 | ``` 55 | 56 | Run the game. 57 | 58 | ```bash 59 | node index.js 60 | ``` 61 | 62 | ## Acknowledgements 63 | 64 | - [Vanya Sergeev](https://sergeev.io) for pointing out my snake collision bug, for advising me to make a single, reusable draw method, for showing me how to properly bind methods between classes, and for overall guidance and inspiration. 65 | - [Devin McIntyre](https://www.dev-eloper.com/) for general advice. 66 | - Panayiotis Nicolaou's [JavaScript Snake for Web](https://medium.freecodecamp.org/think-like-a-programmer-how-to-build-snake-using-only-javascript-html-and-css-7b1479c3339e) for initial logic. 67 | 68 | ## Author 69 | 70 | - [Tania Rascia](https://www.taniarascia.com) 71 | 72 | ## License 73 | 74 | This project is open source and available under the [MIT License](LICENSE). 75 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const { UserInterface } = require('./src/UserInterface') 2 | const { Game } = require('./src/Game') 3 | 4 | module.exports = { 5 | UserInterface, 6 | Game, 7 | } 8 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "snekjs", 3 | "version": "1.0.4", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "blessed": { 8 | "version": "0.1.81", 9 | "resolved": "https://registry.npmjs.org/blessed/-/blessed-0.1.81.tgz", 10 | "integrity": "sha1-+WLWh+wsNpVwrnGvhDJW5tDKESk=" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "snekjs", 3 | "version": "1.0.4", 4 | "description": "A terminal-based Snake implementation written in JavaScript (Node.js) 🐍", 5 | "author": "Tania Rascia", 6 | "main": "index.js", 7 | "bin": { 8 | "snek": "./play.js" 9 | }, 10 | "scripts": { 11 | "play": "node play.js" 12 | }, 13 | "dependencies": { 14 | "blessed": "^0.1.81" 15 | }, 16 | "licence": "MIT", 17 | "keywords": [ 18 | "snake", 19 | "javascript", 20 | "node" 21 | ], 22 | "private": false 23 | } 24 | -------------------------------------------------------------------------------- /play.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { Game } = require('./src/Game') 4 | const { UserInterface } = require('./src/UserInterface') 5 | const game = new Game(new UserInterface()) 6 | 7 | // Begin game 8 | game.start() 9 | -------------------------------------------------------------------------------- /snek.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taniarascia/snek/97429bb400237e54300cadcdad4936610de5aae2/snek.gif -------------------------------------------------------------------------------- /src/Game.js: -------------------------------------------------------------------------------- 1 | const { 2 | GAME_SPEED, 3 | DIRECTIONS, 4 | INITIAL_SNAKE_SIZE, 5 | SNAKE_COLOR, 6 | DOT_COLOR, 7 | DIRECTION_UP, 8 | DIRECTION_RIGHT, 9 | DIRECTION_DOWN, 10 | DIRECTION_LEFT, 11 | } = require('./constants') 12 | 13 | /** 14 | * @class Game 15 | * 16 | * The Game class tracks the state of three things: 17 | * 18 | * 1. The snake, including its direction, velocity, and location 19 | * 2. The dot 20 | * 3. The score 21 | * 22 | * The i/o of the game is handled by a separate UserInterface class, which is 23 | * responsible for detecting all event handlers (key press), creating the 24 | * screen, and drawing elements to the screen. 25 | */ 26 | class Game { 27 | constructor(ui) { 28 | // User interface class for all i/o operations 29 | this.ui = ui 30 | 31 | this.reset() 32 | 33 | // Bind handlers to UI so we can detect input change from the Game class 34 | this.ui.bindHandlers( 35 | this.changeDirection.bind(this), 36 | this.quit.bind(this), 37 | this.start.bind(this) 38 | ) 39 | } 40 | 41 | reset() { 42 | // Set up initial state 43 | this.snake = [] 44 | 45 | for (let i = INITIAL_SNAKE_SIZE; i >= 0; i--) { 46 | this.snake[INITIAL_SNAKE_SIZE - i] = { x: i, y: 0 } 47 | } 48 | 49 | this.dot = {} 50 | this.score = 0 51 | this.currentDirection = DIRECTION_RIGHT 52 | this.changingDirection = false 53 | this.timer = null 54 | 55 | // Generate the first dot before the game begins 56 | this.generateDot() 57 | this.ui.resetScore() 58 | this.ui.render() 59 | } 60 | 61 | /** 62 | * Support WASD and arrow key controls. Update the direction of the snake, and 63 | * do not allow reversal. 64 | */ 65 | changeDirection(_, key) { 66 | if ((key.name === DIRECTION_UP || key.name === 'w') && this.currentDirection !== DIRECTION_DOWN) { 67 | this.currentDirection = DIRECTION_UP 68 | } 69 | if ((key.name === DIRECTION_DOWN || key.name === 's') && this.currentDirection !== DIRECTION_UP) { 70 | this.currentDirection = DIRECTION_DOWN 71 | } 72 | if ((key.name === DIRECTION_LEFT || key.name === 'a') && this.currentDirection !== DIRECTION_RIGHT) { 73 | this.currentDirection = DIRECTION_LEFT 74 | } 75 | if ((key.name === DIRECTION_RIGHT || key.name === 'd') && this.currentDirection !== DIRECTION_LEFT) { 76 | this.currentDirection = DIRECTION_RIGHT 77 | } 78 | } 79 | 80 | /** 81 | * Set the velocity of the snake based on the current direction. Create a new 82 | * head by adding a new segment to the beginning of the snake array, 83 | * increasing by one velocity. Remove one item from the end of the array to 84 | * make the snake move, unless the snake collides with a dot - then increase 85 | * the score and increase the length of the snake by one. 86 | * 87 | */ 88 | moveSnake() { 89 | if (this.changingDirection) { 90 | return 91 | } 92 | this.changingDirection = true 93 | 94 | // Move the head forward by one pixel based on velocity 95 | const head = { 96 | x: this.snake[0].x + DIRECTIONS[this.currentDirection].x, 97 | y: this.snake[0].y + DIRECTIONS[this.currentDirection].y, 98 | } 99 | 100 | this.snake.unshift(head) 101 | 102 | // If the snake lands on a dot, increase the score and generate a new dot 103 | if (this.snake[0].x === this.dot.x && this.snake[0].y === this.dot.y) { 104 | this.score++ 105 | this.ui.updateScore(this.score) 106 | this.generateDot() 107 | } else { 108 | // Otherwise, slither 109 | this.snake.pop() 110 | } 111 | } 112 | 113 | generateRandomPixelCoord(min, max) { 114 | // Get a random coordinate from 0 to max container height/width 115 | return Math.round(Math.random() * (max - min) + min) 116 | } 117 | 118 | generateDot() { 119 | // Generate a dot at a random x/y coordinate 120 | this.dot.x = this.generateRandomPixelCoord(0, this.ui.gameContainer.width - 1) 121 | this.dot.y = this.generateRandomPixelCoord(1, this.ui.gameContainer.height - 1) 122 | 123 | // If the pixel is on a snake, regenerate the dot 124 | this.snake.forEach(segment => { 125 | if (segment.x === this.dot.x && segment.y === this.dot.y) { 126 | this.generateDot() 127 | } 128 | }) 129 | } 130 | 131 | drawSnake() { 132 | // Render each snake segment as a pixel 133 | this.snake.forEach(segment => { 134 | this.ui.draw(segment, SNAKE_COLOR) 135 | }) 136 | } 137 | 138 | drawDot() { 139 | // Render the dot as a pixel 140 | this.ui.draw(this.dot, DOT_COLOR) 141 | } 142 | 143 | isGameOver() { 144 | // If the snake collides with itself, end the game 145 | const collide = this.snake 146 | // Filter out the head 147 | .filter((_, i) => i > 0) 148 | // If head collides with any segment, collision 149 | .some(segment => segment.x === this.snake[0].x && segment.y === this.snake[0].y) 150 | 151 | return ( 152 | collide || 153 | // Right wall 154 | this.snake[0].x >= this.ui.gameContainer.width - 1 || 155 | // Left wall 156 | this.snake[0].x <= -1 || 157 | // Top wall 158 | this.snake[0].y >= this.ui.gameContainer.height - 1 || 159 | // Bottom wall 160 | this.snake[0].y <= -1 161 | ) 162 | } 163 | 164 | showGameOverScreen() { 165 | this.ui.gameOverScreen() 166 | this.ui.render() 167 | } 168 | 169 | tick() { 170 | if (this.isGameOver()) { 171 | this.showGameOverScreen() 172 | clearInterval(this.timer) 173 | this.timer = null 174 | 175 | return 176 | } 177 | 178 | this.changingDirection = false 179 | this.ui.clearScreen() 180 | this.drawDot() 181 | this.moveSnake() 182 | this.drawSnake() 183 | this.ui.render() 184 | } 185 | 186 | start() { 187 | if (!this.timer) { 188 | this.reset() 189 | 190 | this.timer = setInterval(this.tick.bind(this), GAME_SPEED) 191 | } 192 | } 193 | 194 | quit() { 195 | process.exit(0) 196 | } 197 | } 198 | 199 | module.exports = { Game } 200 | -------------------------------------------------------------------------------- /src/UserInterface.js: -------------------------------------------------------------------------------- 1 | const blessed = require('blessed') 2 | 3 | /** 4 | * @class UserInterface 5 | * 6 | * Interact with the input (keyboard directions) and output (creating screen and 7 | * drawing pixels to the screen). Currently this class is one hard-coded 8 | * interface, but could be made into an abstract and extended for multiple 9 | * interfaces - web, terminal, etc. 10 | */ 11 | class UserInterface { 12 | constructor() { 13 | // Blessed is the terminal library API that provides a screen, elements, and 14 | // event handling 15 | this.blessed = blessed 16 | this.screen = blessed.screen() 17 | 18 | // Game title 19 | this.screen.title = 'Snek.js' 20 | 21 | // Create the boxes 22 | this.gameBox = this.createGameBox() 23 | this.scoreBox = this.createScoreBox() 24 | this.gameOverBox = this.createGameOverBox() 25 | 26 | this.gameContainer = this.blessed.box(this.gameBox) 27 | this.scoreContainer = this.blessed.box(this.scoreBox) 28 | } 29 | 30 | createGameBox() { 31 | return { 32 | parent: this.screen, 33 | top: 1, 34 | left: 0, 35 | width: '100%', 36 | height: '100%-1', 37 | style: { 38 | fg: 'black', 39 | bg: 'black', 40 | }, 41 | } 42 | } 43 | 44 | createScoreBox() { 45 | return { 46 | parent: this.screen, 47 | top: 0, 48 | left: 'left', 49 | width: '100%', 50 | height: 1, 51 | tags: true, 52 | style: { 53 | fg: 'white', 54 | bg: 'blue', 55 | }, 56 | } 57 | } 58 | 59 | createGameOverBox() { 60 | return { 61 | parent: this.screen, 62 | top: 'center', 63 | left: 'center', 64 | width: 20, 65 | height: 6, 66 | tags: true, 67 | valign: 'middle', 68 | content: `{center}Game Over!\n\nPress enter to try again{/center}`, 69 | border: { 70 | type: 'line', 71 | }, 72 | style: { 73 | fg: 'black', 74 | bg: 'magenta', 75 | border: { 76 | fg: '#ffffff', 77 | }, 78 | }, 79 | } 80 | } 81 | 82 | bindHandlers(keyPressHandler, quitHandler, enterHandler) { 83 | // Event to handle keypress i/o 84 | this.screen.on('keypress', keyPressHandler) 85 | this.screen.key(['escape', 'q', 'C-c'], quitHandler) 86 | this.screen.key(['enter'], enterHandler) 87 | } 88 | 89 | // Draw a pixel 90 | draw(coord, color) { 91 | this.blessed.box({ 92 | parent: this.gameContainer, 93 | top: coord.y, 94 | left: coord.x, 95 | width: 1, 96 | height: 1, 97 | style: { 98 | fg: color, 99 | bg: color, 100 | }, 101 | }) 102 | } 103 | 104 | // Keep track of how many dots have been consumed and write to the score box 105 | updateScore(score) { 106 | this.scoreContainer.setLine(0, `{bold}Score:{/bold} ${score}`) 107 | } 108 | 109 | // BSOD on game over 110 | gameOverScreen() { 111 | this.gameContainer = this.blessed.box(this.gameOverBox) 112 | } 113 | 114 | // Set to initial screen 115 | clearScreen() { 116 | this.gameContainer.detach() 117 | this.gameContainer = this.blessed.box(this.gameBox) 118 | } 119 | 120 | // Creating a new score box to prevent old snake segments from appearing on it 121 | resetScore() { 122 | this.scoreContainer.detach() 123 | this.scoreContainer = this.blessed.box(this.scoreBox) 124 | this.updateScore(0) 125 | } 126 | 127 | render() { 128 | this.screen.render() 129 | } 130 | } 131 | 132 | module.exports = { UserInterface } 133 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | const GAME_SPEED = 50 2 | const DIRECTIONS = { 3 | up: { x: 0, y: -1 }, 4 | down: { x: 0, y: 1 }, 5 | right: { x: 1, y: 0 }, 6 | left: { x: -1, y: 0 }, 7 | } 8 | const INITIAL_SNAKE_SIZE = 4 9 | const SNAKE_COLOR = 'green' 10 | const DOT_COLOR = 'red' 11 | 12 | const DIRECTION_UP = 'up'; 13 | const DIRECTION_RIGHT = 'right'; 14 | const DIRECTION_DOWN = 'down'; 15 | const DIRECTION_LEFT = 'left'; 16 | 17 | module.exports = { 18 | GAME_SPEED, 19 | DIRECTIONS, 20 | INITIAL_SNAKE_SIZE, 21 | SNAKE_COLOR, 22 | DOT_COLOR, 23 | DIRECTION_UP, 24 | DIRECTION_RIGHT, 25 | DIRECTION_DOWN, 26 | DIRECTION_LEFT, 27 | } 28 | --------------------------------------------------------------------------------