├── 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 | 
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 | 
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) => `${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 | }
--------------------------------------------------------------------------------