├── README.md ├── assets ├── share-image-large.png ├── share-image.png └── sounds │ ├── Dungeon_Theme.mp3 │ ├── drop.mp3 │ ├── finish.mp3 │ ├── moves.mp3 │ ├── multimove.mp3 │ └── points.mp3 ├── board.js ├── constants.js ├── index.html ├── main.js ├── piece.js ├── sound.js └── styles.css /README.md: -------------------------------------------------------------------------------- 1 | # js-tetris 2 | 3 | Tetris game in Modern JavaScript. 4 | 5 | - [Play here](https://tender-haibt-8e9e2b.netlify.app/) or download and open `index.html`. 6 | - [read the blog](https://michael-karen.medium.com/learning-modern-javascript-with-tetris-92d532bcd057?sk=a7c22e45395da8322fc55bf3b23a309d) of how the game was made 7 | - [How to Save High Scores in Local Storage](https://michael-karen.medium.com/how-to-save-high-scores-in-local-storage-7860baca9d68?sk=3027270e1025f015cb6e2fe8018b2142) 8 | 9 | Check out [js-breakout](https://github.com/melcor76/js-breakout)! 10 | 11 | ## Features 12 | 13 | - levels 14 | - high scores 15 | - fx 16 | 17 | ![tetris picture](assets/share-image-large.png) 18 | 19 | ## Course 20 | 21 | I just can't get enough of Tetris so I created an [in-detail interactive course](https://www.educative.io/courses/game-development-js-tetris) on getting started with game development and JavaScript. 22 | 23 | ![image](https://user-images.githubusercontent.com/4782776/118137187-36abd880-b405-11eb-8f86-ca868880919e.png) 24 | 25 | -------------------------------------------------------------------------------- /assets/share-image-large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/melcor76/js-tetris/51cb508716bea8e9385ee03cb5c6432515279261/assets/share-image-large.png -------------------------------------------------------------------------------- /assets/share-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/melcor76/js-tetris/51cb508716bea8e9385ee03cb5c6432515279261/assets/share-image.png -------------------------------------------------------------------------------- /assets/sounds/Dungeon_Theme.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/melcor76/js-tetris/51cb508716bea8e9385ee03cb5c6432515279261/assets/sounds/Dungeon_Theme.mp3 -------------------------------------------------------------------------------- /assets/sounds/drop.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/melcor76/js-tetris/51cb508716bea8e9385ee03cb5c6432515279261/assets/sounds/drop.mp3 -------------------------------------------------------------------------------- /assets/sounds/finish.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/melcor76/js-tetris/51cb508716bea8e9385ee03cb5c6432515279261/assets/sounds/finish.mp3 -------------------------------------------------------------------------------- /assets/sounds/moves.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/melcor76/js-tetris/51cb508716bea8e9385ee03cb5c6432515279261/assets/sounds/moves.mp3 -------------------------------------------------------------------------------- /assets/sounds/multimove.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/melcor76/js-tetris/51cb508716bea8e9385ee03cb5c6432515279261/assets/sounds/multimove.mp3 -------------------------------------------------------------------------------- /assets/sounds/points.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/melcor76/js-tetris/51cb508716bea8e9385ee03cb5c6432515279261/assets/sounds/points.mp3 -------------------------------------------------------------------------------- /board.js: -------------------------------------------------------------------------------- 1 | class Board { 2 | constructor(ctx, ctxNext) { 3 | this.ctx = ctx; 4 | this.ctxNext = ctxNext; 5 | this.init(); 6 | } 7 | 8 | init() { 9 | // Calculate size of canvas from constants. 10 | this.ctx.canvas.width = COLS * BLOCK_SIZE; 11 | this.ctx.canvas.height = ROWS * BLOCK_SIZE; 12 | 13 | // Scale so we don't need to give size on every draw. 14 | this.ctx.scale(BLOCK_SIZE, BLOCK_SIZE); 15 | } 16 | 17 | reset() { 18 | this.grid = this.getEmptyGrid(); 19 | this.piece = new Piece(this.ctx); 20 | this.piece.setStartingPosition(); 21 | this.getNewPiece(); 22 | } 23 | 24 | getNewPiece() { 25 | const { width, height } = this.ctxNext.canvas; 26 | this.next = new Piece(this.ctxNext); 27 | this.ctxNext.clearRect(0, 0, width, height); 28 | this.next.draw(); 29 | } 30 | 31 | draw() { 32 | this.piece.draw(); 33 | this.drawBoard(); 34 | } 35 | 36 | drop() { 37 | let p = moves[KEY.DOWN](this.piece); 38 | if (this.valid(p)) { 39 | this.piece.move(p); 40 | } else { 41 | this.freeze(); 42 | this.clearLines(); 43 | if (this.piece.y === 0) { 44 | // Game over 45 | return false; 46 | } 47 | this.piece = this.next; 48 | this.piece.ctx = this.ctx; 49 | this.piece.setStartingPosition(); 50 | this.getNewPiece(); 51 | } 52 | return true; 53 | } 54 | 55 | clearLines() { 56 | let lines = 0; 57 | 58 | this.grid.forEach((row, y) => { 59 | // If every value is greater than zero then we have a full row. 60 | if (row.every((value) => value > 0)) { 61 | lines++; 62 | 63 | // Remove the row. 64 | this.grid.splice(y, 1); 65 | 66 | // Add zero filled row at the top. 67 | this.grid.unshift(Array(COLS).fill(0)); 68 | } 69 | }); 70 | 71 | if (lines > 0) { 72 | // Calculate points from cleared lines and level. 73 | 74 | account.score += this.getLinesClearedPoints(lines); 75 | account.lines += lines; 76 | 77 | // If we have reached the lines for next level 78 | if (account.lines >= LINES_PER_LEVEL) { 79 | // Goto next level 80 | account.level++; 81 | 82 | // Remove lines so we start working for the next level 83 | account.lines -= LINES_PER_LEVEL; 84 | 85 | // Increase speed of game 86 | time.level = LEVEL[account.level]; 87 | } 88 | } 89 | } 90 | 91 | valid(p) { 92 | return p.shape.every((row, dy) => { 93 | return row.every((value, dx) => { 94 | let x = p.x + dx; 95 | let y = p.y + dy; 96 | return value === 0 || (this.isInsideWalls(x, y) && this.notOccupied(x, y)); 97 | }); 98 | }); 99 | } 100 | 101 | freeze() { 102 | this.piece.shape.forEach((row, y) => { 103 | row.forEach((value, x) => { 104 | if (value > 0) { 105 | this.grid[y + this.piece.y][x + this.piece.x] = value; 106 | } 107 | }); 108 | }); 109 | } 110 | 111 | drawBoard() { 112 | this.grid.forEach((row, y) => { 113 | row.forEach((value, x) => { 114 | if (value > 0) { 115 | this.ctx.fillStyle = COLORS[value]; 116 | this.ctx.fillRect(x, y, 1, 1); 117 | } 118 | }); 119 | }); 120 | } 121 | 122 | getEmptyGrid() { 123 | return Array.from({ length: ROWS }, () => Array(COLS).fill(0)); 124 | } 125 | 126 | isInsideWalls(x, y) { 127 | return x >= 0 && x < COLS && y <= ROWS; 128 | } 129 | 130 | notOccupied(x, y) { 131 | return this.grid[y] && this.grid[y][x] === 0; 132 | } 133 | 134 | rotate(piece, direction) { 135 | // Clone with JSON for immutability. 136 | let p = JSON.parse(JSON.stringify(piece)); 137 | if (!piece.hardDropped) { 138 | // Transpose matrix 139 | for (let y = 0; y < p.shape.length; ++y) { 140 | for (let x = 0; x < y; ++x) { 141 | [p.shape[x][y], p.shape[y][x]] = [p.shape[y][x], p.shape[x][y]]; 142 | } 143 | } 144 | // Reverse the order of the columns. 145 | if (direction === ROTATION.RIGHT) { 146 | p.shape.forEach((row) => row.reverse()); 147 | } else if (direction === ROTATION.LEFT) { 148 | p.shape.reverse(); 149 | } 150 | } 151 | 152 | return p; 153 | } 154 | 155 | getLinesClearedPoints(lines, level) { 156 | const lineClearPoints = 157 | lines === 1 158 | ? POINTS.SINGLE 159 | : lines === 2 160 | ? POINTS.DOUBLE 161 | : lines === 3 162 | ? POINTS.TRIPLE 163 | : lines === 4 164 | ? POINTS.TETRIS 165 | : 0; 166 | pointsSound.play(); 167 | return (account.level + 1) * lineClearPoints; 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /constants.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const COLS = 10; 4 | const ROWS = 20; 5 | const BLOCK_SIZE = 30; 6 | const LINES_PER_LEVEL = 10; 7 | const NO_OF_HIGH_SCORES = 10; 8 | const COLORS = [ 9 | 'none', 10 | 'cyan', 11 | 'blue', 12 | 'orange', 13 | 'yellow', 14 | 'green', 15 | 'purple', 16 | 'red' 17 | ]; 18 | 19 | const SHAPES = [ 20 | [], 21 | [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]], 22 | [[2, 0, 0], [2, 2, 2], [0, 0, 0]], 23 | [[0, 0, 3], // 0,0 -> 2,0 ; 0,1 -> 1,0 ; 0,2 -> 0,0 24 | [3, 3, 3], // 1,0 -> 2,1 ; 1,1 -> 1,1 ; 1,2 -> 0,1 25 | [0, 0, 0]],// 2,0 -> 2,2 ; 2,1 -> 1,2 ; 2,2 -> 0,2 26 | [[4, 4], [4, 4]], 27 | [[0, 5, 5], [5, 5, 0], [0, 0, 0]], 28 | [[0, 6, 0], [6, 6, 6], [0, 0, 0]], 29 | [[7, 7, 0], [0, 7, 7], [0, 0, 0]] 30 | ]; 31 | 32 | const KEY = { 33 | ESC: 27, 34 | SPACE: 32, 35 | LEFT: 37, 36 | UP: 38, 37 | RIGHT: 39, 38 | DOWN: 40, 39 | P: 80, 40 | Q: 81 41 | }; 42 | 43 | const POINTS = { 44 | SINGLE: 100, 45 | DOUBLE: 300, 46 | TRIPLE: 500, 47 | TETRIS: 800, 48 | SOFT_DROP: 1, 49 | HARD_DROP: 2, 50 | }; 51 | 52 | const LEVEL = { 53 | 0: 800, 54 | 1: 720, 55 | 2: 630, 56 | 3: 550, 57 | 4: 470, 58 | 5: 380, 59 | 6: 300, 60 | 7: 220, 61 | 8: 130, 62 | 9: 100, 63 | 10: 80, 64 | 11: 80, 65 | 12: 80, 66 | 13: 70, 67 | 14: 70, 68 | 15: 70, 69 | 16: 50, 70 | 17: 50, 71 | 18: 50, 72 | 19: 30, 73 | 20: 30, 74 | // 29+ is 20ms 75 | }; 76 | 77 | const ROTATION = { 78 | LEFT: 'left', 79 | RIGHT: 'right' 80 | }; 81 | 82 | [COLORS, SHAPES, KEY, POINTS, LEVEL, ROTATION].forEach(item => Object.freeze(item)); 83 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | JavaScript Tetris 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 18 | 19 | 20 | 21 | 22 |
23 |
24 |

HIGH SCORES

25 |
    26 |
    27 | 28 |
    29 |
    30 |

    TETRIS

    31 |

    Score: 0

    32 |

    Lines: 0

    33 |

    Level: 0

    34 | 35 |
    36 |
    37 | 38 | 39 |
    40 | 41 | 42 |
    43 |
    44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | const canvas = document.getElementById('board'); 2 | const ctx = canvas.getContext('2d'); 3 | const canvasNext = document.getElementById('next'); 4 | const ctxNext = canvasNext.getContext('2d'); 5 | 6 | let accountValues = { 7 | score: 0, 8 | level: 0, 9 | lines: 0 10 | }; 11 | 12 | function updateAccount(key, value) { 13 | let element = document.getElementById(key); 14 | if (element) { 15 | element.textContent = value; 16 | } 17 | } 18 | 19 | let account = new Proxy(accountValues, { 20 | set: (target, key, value) => { 21 | target[key] = value; 22 | updateAccount(key, value); 23 | return true; 24 | } 25 | }); 26 | 27 | let requestId = null; 28 | let time = null; 29 | 30 | const moves = { 31 | [KEY.LEFT]: (p) => ({ ...p, x: p.x - 1 }), 32 | [KEY.RIGHT]: (p) => ({ ...p, x: p.x + 1 }), 33 | [KEY.DOWN]: (p) => ({ ...p, y: p.y + 1 }), 34 | [KEY.SPACE]: (p) => ({ ...p, y: p.y + 1 }), 35 | [KEY.UP]: (p) => board.rotate(p, ROTATION.RIGHT), 36 | [KEY.Q]: (p) => board.rotate(p, ROTATION.LEFT) 37 | }; 38 | 39 | let board = new Board(ctx, ctxNext); 40 | 41 | initNext(); 42 | showHighScores(); 43 | 44 | function initNext() { 45 | // Calculate size of canvas from constants. 46 | ctxNext.canvas.width = 4 * BLOCK_SIZE; 47 | ctxNext.canvas.height = 4 * BLOCK_SIZE; 48 | ctxNext.scale(BLOCK_SIZE, BLOCK_SIZE); 49 | } 50 | 51 | function addEventListener() { 52 | document.removeEventListener('keydown', handleKeyPress); 53 | document.addEventListener('keydown', handleKeyPress); 54 | } 55 | 56 | function handleKeyPress(event) { 57 | if (event.keyCode === KEY.P) { 58 | pause(); 59 | } 60 | if (event.keyCode === KEY.ESC) { 61 | gameOver(); 62 | } else if (moves[event.keyCode]) { 63 | event.preventDefault(); 64 | // Get new state 65 | let p = moves[event.keyCode](board.piece); 66 | if (event.keyCode === KEY.SPACE) { 67 | // Hard drop 68 | if (document.querySelector('#pause-btn').style.display === 'block') { 69 | dropSound.play(); 70 | }else{ 71 | return; 72 | } 73 | 74 | while (board.valid(p)) { 75 | account.score += POINTS.HARD_DROP; 76 | board.piece.move(p); 77 | p = moves[KEY.DOWN](board.piece); 78 | } 79 | board.piece.hardDrop(); 80 | } else if (board.valid(p)) { 81 | if (document.querySelector('#pause-btn').style.display === 'block') { 82 | movesSound.play(); 83 | } 84 | board.piece.move(p); 85 | if (event.keyCode === KEY.DOWN && 86 | document.querySelector('#pause-btn').style.display === 'block') { 87 | account.score += POINTS.SOFT_DROP; 88 | } 89 | } 90 | } 91 | } 92 | 93 | function resetGame() { 94 | account.score = 0; 95 | account.lines = 0; 96 | account.level = 0; 97 | board.reset(); 98 | time = { start: performance.now(), elapsed: 0, level: LEVEL[account.level] }; 99 | } 100 | 101 | function play() { 102 | addEventListener(); 103 | if (document.querySelector('#play-btn').style.display == '') { 104 | resetGame(); 105 | } 106 | 107 | // If we have an old game running then cancel it 108 | if (requestId) { 109 | cancelAnimationFrame(requestId); 110 | } 111 | 112 | animate(); 113 | document.querySelector('#play-btn').style.display = 'none'; 114 | document.querySelector('#pause-btn').style.display = 'block'; 115 | backgroundSound.play(); 116 | } 117 | 118 | function animate(now = 0) { 119 | time.elapsed = now - time.start; 120 | if (time.elapsed > time.level) { 121 | time.start = now; 122 | if (!board.drop()) { 123 | gameOver(); 124 | return; 125 | } 126 | } 127 | 128 | // Clear board before drawing new state. 129 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 130 | 131 | board.draw(); 132 | requestId = requestAnimationFrame(animate); 133 | } 134 | 135 | function gameOver() { 136 | cancelAnimationFrame(requestId); 137 | 138 | ctx.fillStyle = 'black'; 139 | ctx.fillRect(1, 3, 8, 1.2); 140 | ctx.font = '1px Arial'; 141 | ctx.fillStyle = 'red'; 142 | ctx.fillText('GAME OVER', 1.8, 4); 143 | 144 | sound.pause(); 145 | finishSound.play(); 146 | checkHighScore(account.score); 147 | 148 | document.querySelector('#pause-btn').style.display = 'none'; 149 | document.querySelector('#play-btn').style.display = ''; 150 | } 151 | 152 | function pause() { 153 | if (!requestId) { 154 | document.querySelector('#play-btn').style.display = 'none'; 155 | document.querySelector('#pause-btn').style.display = 'block'; 156 | animate(); 157 | backgroundSound.play(); 158 | return; 159 | } 160 | 161 | cancelAnimationFrame(requestId); 162 | requestId = null; 163 | 164 | ctx.fillStyle = 'black'; 165 | ctx.fillRect(1, 3, 8, 1.2); 166 | ctx.font = '1px Arial'; 167 | ctx.fillStyle = 'yellow'; 168 | ctx.fillText('PAUSED', 3, 4); 169 | document.querySelector('#play-btn').style.display = 'block'; 170 | document.querySelector('#pause-btn').style.display = 'none'; 171 | sound.pause(); 172 | } 173 | 174 | function showHighScores() { 175 | const highScores = JSON.parse(localStorage.getItem('highScores')) || []; 176 | const highScoreList = document.getElementById('highScores'); 177 | 178 | highScoreList.innerHTML = highScores 179 | .map((score) => `
  1. ${score.score} - ${score.name}`) 180 | .join(''); 181 | } 182 | 183 | function checkHighScore(score) { 184 | const highScores = JSON.parse(localStorage.getItem('highScores')) || []; 185 | const lowestScore = highScores[NO_OF_HIGH_SCORES - 1]?.score ?? 0; 186 | 187 | if (score > lowestScore) { 188 | const name = prompt('You got a highscore! Enter name:'); 189 | const newScore = { score, name }; 190 | saveHighScore(newScore, highScores); 191 | showHighScores(); 192 | } 193 | } 194 | 195 | function saveHighScore(score, highScores) { 196 | highScores.push(score); 197 | highScores.sort((a, b) => b.score - a.score); 198 | highScores.splice(NO_OF_HIGH_SCORES); 199 | 200 | localStorage.setItem('highScores', JSON.stringify(highScores)); 201 | } 202 | -------------------------------------------------------------------------------- /piece.js: -------------------------------------------------------------------------------- 1 | class Piece { 2 | constructor(ctx) { 3 | this.ctx = ctx; 4 | this.spawn(); 5 | } 6 | 7 | spawn() { 8 | this.typeId = this.randomizeTetrominoType(COLORS.length - 1); 9 | this.shape = SHAPES[this.typeId]; 10 | this.color = COLORS[this.typeId]; 11 | this.x = 0; 12 | this.y = 0; 13 | this.hardDropped = false; 14 | } 15 | 16 | draw() { 17 | this.ctx.fillStyle = this.color; 18 | this.shape.forEach((row, y) => { 19 | row.forEach((value, x) => { 20 | if (value > 0) { 21 | this.ctx.fillRect(this.x + x, this.y + y, 1, 1); 22 | } 23 | }); 24 | }); 25 | } 26 | 27 | move(p) { 28 | if (!this.hardDropped) { 29 | this.x = p.x; 30 | this.y = p.y; 31 | } 32 | this.shape = p.shape; 33 | } 34 | 35 | hardDrop() { 36 | this.hardDropped = true; 37 | } 38 | 39 | setStartingPosition() { 40 | this.x = this.typeId === 4 ? 4 : 3; 41 | } 42 | 43 | randomizeTetrominoType(noOfTypes) { 44 | return Math.floor(Math.random() * noOfTypes + 1); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /sound.js: -------------------------------------------------------------------------------- 1 | class Sound { 2 | constructor(parent){ 3 | this.parent = parent; 4 | this.sounds = []; 5 | this.muted = true; 6 | } 7 | 8 | create(src, id, loop = false){ 9 | let audio = document.createElement("audio"); 10 | audio.src = src; 11 | audio.id = id; 12 | audio.muted = true; 13 | this.sounds.push(audio); 14 | this.parent.append(audio); 15 | 16 | if(loop){ 17 | audio.setAttribute("loop", "") 18 | } 19 | 20 | return audio; 21 | } 22 | 23 | } 24 | 25 | Sound.prototype.soundSetting = function(){ 26 | let soundItems = document.querySelectorAll(".sound-item"); 27 | for(let soundItem of soundItems){ 28 | soundItem.addEventListener("click", (e)=>{ 29 | this.muteToggle(); 30 | }); 31 | } 32 | }; 33 | 34 | Sound.prototype.muteToggle = function(){ 35 | if(!this.muted){ 36 | for(let sound of this.sounds){ 37 | sound.muted = true; 38 | } 39 | document.querySelector("#sound-speaker").innerHTML = "\u{1F507}"; 40 | document.querySelector("#sound-description").innerHTML = "off"; 41 | this.muted = true; 42 | }else{ 43 | for(let sound of this.sounds){ 44 | sound.muted = false; 45 | } 46 | document.querySelector("#sound-speaker").innerHTML = "\u{1F509}"; 47 | document.querySelector("#sound-description").innerHTML = "on"; 48 | this.muted = false; 49 | } 50 | }; 51 | 52 | Sound.prototype.pause = function(){ 53 | for(let sound of this.sounds){ 54 | sound.pause(); 55 | } 56 | } 57 | 58 | Sound.prototype.play = function(){ 59 | for(let sound of this.sounds){ 60 | sound.play(); 61 | } 62 | } 63 | 64 | let sound = new Sound(document.querySelector("#sound-div")), 65 | backgroundSound = sound.create("assets/sounds/Dungeon_Theme.mp3", "background_sound", true), 66 | movesSound = sound.create("assets/sounds/moves.mp3", "moves_sound"), 67 | dropSound = sound.create("assets/sounds/drop.mp3", "drop_sound"), 68 | pointsSound = sound.create("assets/sounds/points.mp3", "points_sound"), 69 | finishSound = sound.create("assets/sounds/finish.mp3", "finish_sound");; 70 | sound.muteToggle(); 71 | sound.soundSetting(); -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | font-family: 'Press Start 2P', cursive; 3 | } 4 | 5 | .grid { 6 | display: grid; 7 | grid-template-columns: 350px 320px 200px; 8 | position: absolute; 9 | left: 50%; 10 | transform: translate(-50%, 0); 11 | } 12 | 13 | .left-column { 14 | display: flex; 15 | flex-direction: column; 16 | } 17 | 18 | .right-column { 19 | display: flex; 20 | flex-direction: column; 21 | justify-content: space-between; 22 | } 23 | 24 | .game-board { 25 | border: solid 2px; 26 | } 27 | 28 | .play-button { 29 | background-color: #4caf50; 30 | font-size: 16px; 31 | padding: 15px 30px; 32 | cursor: pointer; 33 | } 34 | 35 | #pause-btn { 36 | display: none; 37 | } 38 | 39 | #sound-speaker{ 40 | font-size:30px; 41 | } 42 | 43 | .sound-item{ 44 | cursor:pointer; 45 | } --------------------------------------------------------------------------------