In this post I will show you the Tetris game programmed in pure JavaScript, totally free and open source.
This block game is programmed with JavaScript and uses canvas to paint the game. I also use Bootstrap for the layout of the buttons and the page in general, with a bit of SweetAlert for the alerts.
Although it seems simple to do, it is one of the jobs that has cost me the most and of which I am most proud. It was complex (for me) to understand all the logic for collisions, rotations, row deletion, part movement, limits, etc.
Among the features of the game we find:
- Sounds: background music, sound when the piece cannot be rotated, when a complete row is made and when the tetromino touches the ground
- Colors: each piece has a random color chosen at runtime
- Rotations: pieces can be rotated to accommodate them and accumulate points
- Mobile compatible: because it is web, I have added some buttons to be able to play it on mobile phones and tablets, but it can also be played with the keyboard
- Open source: you can modify the game, the board, the length, speed, pieces, rotations, etc.
- Tetris port: behaves like any normal tetris game
- Game pause: the game can be paused or resumed at any time
Let’s see then the details of this game programmed in JS. Throughout the post I will show you how this game is programmed, I will also leave you a demo and the complete code which is FOSS.
Note: figure, piece and tetromino will be used synonymously in this post.
General algorithm of the Tetris game
The algorithm is simple. We have a Cartesian plane where the coordinates X
and Y
exist. We have 3 things:
- Game board: the board where everything will be drawn or displayed
- Existing pieces: the points or pieces that have already fallen before; that is, the figures that stayed there
- Current piece: the piece that is currently going down and that the player can move or rotate
Now we do the following: we draw the game board (empty), then we overlap the existing pieces and finally we place the current piece (the one that is going down).
In each movement or attempted rotation, we check if the piece does not collapse with the wall or with another figure below. When the part is rotated, we do a simulation to see if the points, after being rotated, will not collapse with another part.
Furthermore, when it is detected that the piece has hit the ground, a timer is started that will put the next piece in certain milliseconds (this way the player has time to move the piece).
Before displaying another shape, the points of the current shape are moved to the existing parts.
The rest are collisions and work with arrays. For example, to check if a row is full, we go through each point of the array at a certain position of Y and check if it is taken.
All the game drawing is done in a requestAnimationFrame
and we draw an array on Canvas using JavaScript.
Game music
The game has various sounds. All sounds are injected and hidden in the DOM. They are init like this:
initSounds() {
this.sounds.background = Utils.loadSound("assets/New Donk City_ Daytime 8 Bit.mp3", true);
this.sounds.success = Utils.loadSound("assets/success.wav");
this.sounds.denied = Utils.loadSound("assets/denied.wav");
this.sounds.tap = Utils.loadSound("assets/tap.wav");
}
In this case the background sound is reproduced in a loop, so that it repeats infinitely. Then, we reproduce it like this (line 1):
resumeGame() {
this.sounds.background.play();
this.refreshScore();
this.paused = false;
this.canPlay = true;
this.intervalId = setInterval(this.mainLoop.bind(this), Game.PIECE_SPEED);
}
Helper classes
Point
A point has two things: X and Y coordinates. And a Tetromino has several points that make it up.
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
}
Tetromino
Like I said, a tetris figure is made up of several points. In addition, it has several rotations. The Z, for example, has only 2, but the J has 4. The rotations are also defined as a Tetromino, and they are exchanged when rotating.
To keep track of the rotation the figure is in, an index is kept. The rotations are nothing more than an array that has several Tetrominos, which represents all possible rotations.
class Tetromino {
constructor(rotations) {
this.rotations = rotations;
this.rotationIndex = 0;
this.points = this.rotations[this.rotationIndex];
const randomColor = Utils.getRandomColor();
this.rotations.forEach(points => {
points.forEach(point => {
point.color = randomColor;
});
});
this.incrementRotationIndex();
}
getPoints() {
return this.points;
}
incrementRotationIndex() {
if (this.rotations.length <= 0) {
this.rotationIndex = 0;
} else {
if (this.rotationIndex + 1 >= this.rotations.length) {
this.rotationIndex = 0;
} else {
this.rotationIndex++;
}
}
}
getNextRotation() {
return this.rotations[this.rotationIndex];
}
}
As you can see, the color is chosen in line 6. At the moment of choosing the figure, each point of each rotation of the same is colored with the random color.
Useful functions
Before continuing, let’s look at the useful functions. Among them we have the function that loads the sound, the one that chooses a random color or the one that chooses a random number to know which figure to choose:
class Utils {
static getRandomNumberInRange = (min, max) => {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
static getRandomColor() {
return Game.COLORS[Utils.getRandomNumberInRange(0, Game.COLORS.length - 1)];
}
static loadSound(src, loop) {
const sound = document.createElement("audio");
sound.src = src;
sound.setAttribute("preload", "auto");
sound.setAttribute("controls", "none");
sound.loop = loop || false;
sound.style.display = "none";
document.body.appendChild(sound);
return sound;
}
}
For example, the getRandomColor
function chooses a random color from the static array of the Game
class that we will see next. And this function reuses the function called getRandomNumberInRange
which returns a number in a certain range.
Game operation
Let’s start by looking at the constants of the game, such as the size of the board, the colors, the score that is given to the user when he makes a row of points or the random colors to choose from:
// Square length in pixels
static SQUARE_LENGTH = screen.width > 420 ? 30 : 20;
static COLUMNS = 10;
static ROWS = 20;
static CANVAS_WIDTH = this.SQUARE_LENGTH * this.COLUMNS;
static CANVAS_HEIGHT = this.SQUARE_LENGTH * this.ROWS;
static EMPTY_COLOR = "#eaeaea";
static BORDER_COLOR = "#ffffff";
static DELETED_ROW_COLOR = "#d81c38";
// When a piece collapses with something at its bottom, how many time wait for putting another piece? (in ms)
static TIMEOUT_LOCK_PUT_NEXT_PIECE = 300;
// Speed of falling piece (in ms)
static PIECE_SPEED = 300;
// Animation time when a row is being deleted
static DELETE_ROW_ANIMATION = 500;
// Score to add when a square dissapears (for each square)
static PER_SQUARE_SCORE = 1;
static COLORS = [
"#ffd300",
"#de38c8",
"#652ec7",
"#33135c",
"#13ca91",
"#ff9472",
"#35212a",
"#ff8b8b",
"#28cf75",
"#00a9fe",
"#04005e",
"#120052",
"#272822",
"#f92672",
"#66d9ef",
"#a6e22e",
"#fd971f",
];
The color array can be modified to your liking, either by changing the colors or adding more, to add randomness.
We also have certain interesting parameters such as the size of each square in pixels (this affects the size of the board) or the milliseconds to indicate the speed at which the piece moves down, the duration of the animation or the time the player has to move the piece if it has touched the ground.
Game init function
It all starts in the init function, but the constructor where we define several things is also important:
constructor(canvasId) {
this.canvasId = canvasId;
this.timeoutFlag = false;
this.board = [];
this.existingPieces = [];
this.globalX = 0;
this.globalY = 0;
this.paused = true;
this.currentFigure = null;
this.sounds = {};
this.canPlay = false;
this.intervalId = null;
this.init();
}
The game receives the id of the canvas where it is going to be drawn. In this way we could make them two Tetris game boards, using the same code.
We also have various flags, the definition of the board, the sounds, and so on. Now let’s see the init and restart of this open source tetris game:
init() {
this.showWelcome();
this.initDomElements();
this.initSounds();
this.resetGame();
this.draw();
this.initControls();
}
resetGame() {
this.score = 0;
this.sounds.success.currentTime = 0;
this.sounds.success.pause();
this.sounds.background.currentTime = 0;
this.sounds.background.pause();
this.initBoardAndExistingPieces();
this.chooseRandomFigure();
this.restartGlobalXAndY();
this.syncExistingPiecesWithBoard();
this.refreshScore();
this.pauseGame();
}
One important thing to note here is the syncExistingPiecesWithBoard
function. What this function does is clean the board and place on it (that is, modify the indexes of the array) the existing pieces.
Draw on canvas
The function that draws the entire tetris game on the canvas is the following. It will be invoked every 17 milliseconds using requestAnimationFrame
:
draw() {
let x = 0, y = 0;
for (const row of this.board) {
x = 0;
for (const point of row) {
this.canvasContext.fillStyle = point.color;
this.canvasContext.fillRect(x, y, Game.SQUARE_LENGTH, Game.SQUARE_LENGTH);
this.canvasContext.restore();
this.canvasContext.strokeStyle = Game.BORDER_COLOR;
this.canvasContext.strokeRect(x, y, Game.SQUARE_LENGTH, Game.SQUARE_LENGTH);
x += Game.SQUARE_LENGTH;
}
y += Game.SQUARE_LENGTH;
}
setTimeout(() => {
requestAnimationFrame(this.draw.bind(this));
}, 17)
}
This function is the one that you can modify if you want to draw the game in another place; for example in a table, with SVG, in the console, and so on.
Get random figure
We have the function where we define the figures and their rotations. This method will return a random Tetromino on each invocation:
getRandomFigure() {
/*
* Nombres de los tetrominós tomados de: https://www.joe.co.uk/gaming/tetris-block-names-221127
* Regresamos una nueva instancia en cada ocasión, pues si definiéramos las figuras en constantes o variables, se tomaría la misma
* referencia en algunas ocasiones
* */
switch (Utils.getRandomNumberInRange(1, 7)) {
case 1:
/*
El cuadrado (smashboy)
**
**
*/
return new Tetromino([
[new Point(0, 0), new Point(1, 0), new Point(0, 1), new Point(1, 1)]
]);
case 2:
/*
La línea (hero)
****
*/
return new Tetromino([
[new Point(0, 0), new Point(1, 0), new Point(2, 0), new Point(3, 0)],
[new Point(0, 0), new Point(0, 1), new Point(0, 2), new Point(0, 3)],
]);
case 3:
/*
La L (orange ricky)
*
***
*/
return new Tetromino([
[new Point(0, 1), new Point(1, 1), new Point(2, 1), new Point(2, 0)],
[new Point(0, 0), new Point(0, 1), new Point(0, 2), new Point(1, 2)],
[new Point(0, 0), new Point(0, 1), new Point(1, 0), new Point(2, 0)],
[new Point(0, 0), new Point(1, 0), new Point(1, 1), new Point(1, 2)],
]);
case 4:
/*
La J (blue ricky)
*
***
*/
return new Tetromino([
[new Point(0, 0), new Point(0, 1), new Point(1, 1), new Point(2, 1)],
[new Point(0, 0), new Point(1, 0), new Point(0, 1), new Point(0, 2)],
[new Point(0, 0), new Point(1, 0), new Point(2, 0), new Point(2, 1)],
[new Point(0, 2), new Point(1, 2), new Point(1, 1), new Point(1, 0)],
]);
case 5:
/*
La Z (Cleveland Z)
**
**
*/
return new Tetromino([
[new Point(0, 0), new Point(1, 0), new Point(1, 1), new Point(2, 1)],
[new Point(0, 1), new Point(1, 1), new Point(1, 0), new Point(0, 2)],
]);
case 6:
/*
La otra Z (Rhode island Z)
**
**
*/
return new Tetromino([
[new Point(0, 1), new Point(1, 1), new Point(1, 0), new Point(2, 0)],
[new Point(0, 0), new Point(0, 1), new Point(1, 1), new Point(1, 2)],
]);
case 7:
default:
/*
La T (Teewee)
*
***
*/
return new Tetromino([
[new Point(0, 1), new Point(1, 1), new Point(1, 0), new Point(2, 1)],
[new Point(0, 0), new Point(0, 1), new Point(0, 2), new Point(1, 1)],
[new Point(0, 0), new Point(1, 0), new Point(2, 0), new Point(1, 1)],
[new Point(0, 1), new Point(1, 0), new Point(1, 1), new Point(1, 2)],
]);
}
}
Right here is where we are using the Point
class and the Tetromino
class. Remember that each Tetromino will receive an array of all possible rotations. And each rotation is in turn an array of points that have different coordinates.
These coordinates are not modified internally at the point, but are placed from a global X and Y on the board.
If you want to define other shapes or modify the rotations, this is where you have to make the changes.
Collisions and movements
I know that there are engines for video game development but in this case I wanted to do everything by hand. Therefore I have created my own functions to know if a point is out of bounds, if a point is valid, and so on.
Let’s first look at the point collision functions that check the existing board and pieces:
/**
*
* @param point An object that has x and y properties; the coordinates shouldn't be global, but relative to the point
* @returns {boolean}
*/
relativePointOutOfLimits(point) {
const absoluteX = point.x + this.globalX;
const absoluteY = point.y + this.globalY;
return this.absolutePointOutOfLimits(absoluteX, absoluteY);
}
/**
* @param absoluteX
* @param absoluteY
* @returns {boolean}
*/
absolutePointOutOfLimits(absoluteX, absoluteY) {
return absoluteX < 0 || absoluteX > Game.COLUMNS - 1 || absoluteY < 0 || absoluteY > Game.ROWS - 1;
}
// It returns true even if the point is not valid (for example if it is out of limit, because it is not the function's responsibility)
isEmptyPoint(x, y) {
if (!this.existingPieces[y]) return true;
if (!this.existingPieces[y][x]) return true;
if (this.existingPieces[y][x].taken) {
return false;
} else {
return true;
}
}
/**
* Check if a point (in the game board) is valid to put another point there.
* @param point the point to check, with relative coordinates
* @param points an array of points that conforms a figure
*/
isValidPoint(point, points) {
const emptyPoint = this.isEmptyPoint(this.globalX + point.x, this.globalY + point.y);
const hasSameCoordinateOfFigurePoint = points.findIndex(p => {
return p.x === point.x && p.y === point.y;
}) !== -1;
const outOfLimits = this.relativePointOutOfLimits(point);
if ((emptyPoint || hasSameCoordinateOfFigurePoint) && !outOfLimits) {
return true;
} else {
return false;
}
}
We are using the global coordinates of X and also of Y, because remember that each point in the figure is independent.
Now let’s see the functions to move or rotate the figure:
figureCanMoveRight() {
if (!this.currentFigure) return false;
for (const point of this.currentFigure.getPoints()) {
const newPoint = new Point(point.x + 1, point.y);
if (!this.isValidPoint(newPoint, this.currentFigure.getPoints())) {
return false;
}
}
return true;
}
figureCanMoveLeft() {
if (!this.currentFigure) return false;
for (const point of this.currentFigure.getPoints()) {
const newPoint = new Point(point.x - 1, point.y);
if (!this.isValidPoint(newPoint, this.currentFigure.getPoints())) {
return false;
}
}
return true;
}
figureCanMoveDown() {
if (!this.currentFigure) return false;
for (const point of this.currentFigure.getPoints()) {
const newPoint = new Point(point.x, point.y + 1);
if (!this.isValidPoint(newPoint, this.currentFigure.getPoints())) {
return false;
}
}
return true;
}
figureCanRotate() {
const newPointsAfterRotate = this.currentFigure.getNextRotation();
for (const rotatedPoint of newPointsAfterRotate) {
if (!this.isValidPoint(rotatedPoint, this.currentFigure.getPoints())) {
return false;
}
}
return true;
}
rotateFigure() {
if (!this.figureCanRotate()) {
this.sounds.denied.currentTime = 0;
this.sounds.denied.play();
return;
}
this.currentFigure.points = this.currentFigure.getNextRotation();
this.currentFigure.incrementRotationIndex();
}
What we do is simulate the movement and check if each point is still valid. We also play a sound in case the figure cannot be rotated.
Deleting full rows
This is one of the functions that took me the most work to program. What it does is check and remove the filled rows. It looks like this:
verifyAndDeleteFullRows() {
const yCoordinates = this.getPointsToDelete();
if (yCoordinates.length <= 0) return;
this.addScore(yCoordinates);
this.sounds.success.currentTime = 0;
this.sounds.success.play();
this.changeDeletedRowColor(yCoordinates);
this.canPlay = false;
setTimeout(() => {
this.sounds.success.pause();
this.removeRowsFromExistingPieces(yCoordinates);
this.syncExistingPiecesWithBoard();
const invertedCoordinates = Array.from(yCoordinates);
// Now the coordinates are in descending order
invertedCoordinates.reverse();
for (let coordenadaY of invertedCoordinates) {
for (let y = Game.ROWS - 1; y >= 0; y--) {
for (let x = 0; x < this.existingPieces[y].length; x++) {
if (y < coordenadaY) {
let contador = 0;
let yAuxiliar = y;
while (this.isEmptyPoint(x, yAuxiliar + 1) && !this.absolutePointOutOfLimits(x, yAuxiliar + 1) && contador < yCoordinates.length) {
this.existingPieces[yAuxiliar + 1][x] = this.existingPieces[yAuxiliar][x];
this.existingPieces[yAuxiliar][x] = {
color: Game.EMPTY_COLOR,
taken: false,
}
this.syncExistingPiecesWithBoard();
contador++;
yAuxiliar++;
}
}
}
}
}
this.syncExistingPiecesWithBoard()
this.canPlay = true;
}, Game.DELETE_ROW_ANIMATION);
}
We must obtain the Y coordinate of all the rows that are already filled. For each one, we check if we can move all the points down. In case they are, we lower them, but we do not lower them beyond the number of deleted rows.
Also, this feature increases the score. The function that refreshes it is:
addScore(rows) {
this.score += Game.PER_SQUARE_SCORE * Game.COLUMNS * rows.length;
this.refreshScore();
}
refreshScore() {
this.$score.textContent = `Score: ${this.score}`;
}
The score is calculated according to the number of points that were removed multiplied by the score per square removed.
Game loop
Now let’s look at the game loop. According to the interval in milliseconds defined above, we are lowering the tetromino while possible. In case the piece can no longer be moved, a timer is started to give the player an opportunity to move the piece.
When the timer runs out, if the tetromino still can’t move, we add the current part to the existing parts and then select another shape:
mainLoop() {
if (!this.canPlay) {
return;
}
// If figure can move down, move down
if (this.figureCanMoveDown()) {
this.globalY++;
} else {
// If figure cannot, then we start a timeout because
// player can move figure to keep it going down
// for example when the figure collapses with another points but there's remaining
// space at the left or right and the player moves there so the figure can keep going down
if (this.timeoutFlag) return;
this.timeoutFlag = true;
setTimeout(() => {
this.timeoutFlag = false;
// If the time expires, we re-check if figure cannot keep going down. If it can
// (because player moved it) then we return and keep the loop
if (this.figureCanMoveDown()) {
return;
}
// At this point, we know that the figure collapsed either with the floor
// or with another point. So we move all the figure to the existing pieces array
this.sounds.tap.currentTime = 0;
this.sounds.tap.play();
this.moveFigurePointsToExistingPieces();
if (this.playerLoses()) {
Swal.fire("Juego terminado", "Inténtalo de nuevo");
this.sounds.background.pause();
this.canPlay = false;
this.resetGame();
return;
}
this.verifyAndDeleteFullRows();
this.chooseRandomFigure();
this.syncExistingPiecesWithBoard()
}, Game.TIMEOUT_LOCK_PUT_NEXT_PIECE);
}
this.syncExistingPiecesWithBoard();
}
It is also in this loop where we check if the player loses.
I must admit that this feature needs to be improved as it currently checks for pieces in the second row, but it should be smarter.
playerLoses() {
// Check if there's something at Y 1. Maybe it is not fair for the player, but it works
for (const point of this.existingPieces[1]) {
if (point.taken) {
return true;
}
}
return false;
}
Controls for playing blocks in JavaScript
The block game or tetris in JavaScript that I have programmed can be played with the keyboard or with the buttons. It is a good time to show the HTML code of the game, where we see the canvas and the buttons:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="css/bootstrap.min.css">
<link rel="stylesheet" href="css/all.min.css">
<style>
body {
padding-bottom: 70px;
}
</style>
<title>Tetris en JavaScript by Parzibyte - Jugar tetris en línea</title>
</head>
<body>
<main>
<div class="container-fluid">
<div class="row">
<div class="col-12 text-center">
<h1>Tetris - By Parzibyte</h1>
<h2 id="puntaje">Presiona <kbd>P</kbd> o pulsa el botón para comenzar</h2>
</div>
<div class="col-12 text-center">
<canvas id="canvas"></canvas>
</div>
<div class="col-12 text-center">
<div class="mt-2">
<button id="btnIniciar" class="btn btn-success"><i class="fas fa-play"></i></button>
<button hidden id="btnPausar" class="btn btn-success"><i class="fas fa-pause"></i></button>
<button id="btnIzquierda" class="btn btn-success"><i class="fas fa-arrow-left"></i></button>
<button id="btnAbajo" class="btn btn-success"><i class="fas fa-arrow-down"></i></button>
<button id="btnDerecha" class="btn btn-success"><i class="fas fa-arrow-right"></i></button>
<button id="btnRotar" class="btn btn-success"><i class="fas fa-undo"></i></button>
<button class="btn btn-danger" id="reset">Reset</button>
</div>
</div>
</div>
</div>
</main>
<footer class="px-2 py-2 fixed-bottom bg-dark">
<span class="text-muted">Tetris en JavaScript creado por
<a class="text-white" href="//parzibyte.me/blog">Parzibyte</a>
|
<a target="_blank" class="text-white" href="//github.com/parzibyte/tetris-javascript">
Ver código fuente
</a>
</span>
</footer>
</body>
<script src="js/sweetalert2.min.js"></script>
<script src="js/tetris.js"></script>
</html>
Each button has an id, which I then retrieve within the game using querySelector
:
initDomElements() {
this.$canvas = document.querySelector("#" + this.canvasId);
this.$score = document.querySelector("#puntaje");
this.$btnPause = document.querySelector("#btnPausar");
this.$btnResume = document.querySelector("#btnIniciar");
this.$btnRotate = document.querySelector("#btnRotar");
this.$btnDown = document.querySelector("#btnAbajo");
this.$btnRight = document.querySelector("#btnDerecha");
this.$btnLeft = document.querySelector("#btnIzquierda");
this.$canvas.setAttribute("width", Game.CANVAS_WIDTH + "px");
this.$canvas.setAttribute("height", Game.CANVAS_HEIGHT + "px");
this.canvasContext = this.$canvas.getContext("2d");
}
Now let’s see the controls, both the keyboard and the buttons. Each invokes the “try to move right” functions and all other positions:
initControls() {
document.addEventListener("keydown", (e) => {
const {code} = e;
if (!this.canPlay && code !== "KeyP") {
return;
}
switch (code) {
case "ArrowRight":
this.attemptMoveRight();
break;
case "ArrowLeft":
this.attemptMoveLeft();
break;
case "ArrowDown":
this.attemptMoveDown();
break;
case "KeyR":
this.attemptRotate();
break;
case "KeyP":
this.pauseOrResumeGame();
break;
}
this.syncExistingPiecesWithBoard();
});
this.$btnDown.addEventListener("click", () => {
if (!this.canPlay) return;
this.attemptMoveDown();
});
this.$btnRight.addEventListener("click", () => {
if (!this.canPlay) return;
this.attemptMoveRight();
});
this.$btnLeft.addEventListener("click", () => {
if (!this.canPlay) return;
this.attemptMoveLeft();
});
this.$btnRotate.addEventListener("click", () => {
if (!this.canPlay) return;
this.attemptRotate();
});
[this.$btnPause, this.$btnResume].forEach($btn => $btn.addEventListener("click", () => {
this.pauseOrResumeGame();
}));
}
Here you can see that we use the canPlay
flag, which we deactivate or activate according to our convenience. For example, it cannot be played while the game is paused or while the row removal animation is playing.
Putting it all together
I can’t put or explain all the code here. Remember that the only thing we need is a canvas where we can draw the whole set of blocks. The rest is in the code, especially in the Game
class. You are free to explore it.
It really took me a lot of time and effort to make this game; you can see all its evolution through commits.
I leave you a YouTube video for the demonstration (in spanish):
The complete and open source code is on my GitHub: https://github.com/parzibyte/tetris-javascript
You can try the demo here: https://parzibyte.github.io/tetris-javascript/
I also take the opportunity to invite you to read more about JavaScript and Videogames on my blog.