Learning Modern JavaScript with Tetris

Michael Karén
Dec 2 · 14 min read

Today, I’m taking you along for a journey in game development with the classic game of Tetris. We are going to touch upon concepts like graphics, game loops, and collision detection. In the end, we have a fully functioning game with points and levels. Part of the journey is using concepts of modern JavaScript, meaning features introduced in ECMAScript 2015 (ES6) like:

I hope you pick up something new that you can bring into your arsenal of JavaScript tricks!

If you are creating the project and get an error from the code snippets, then check the code in the repository on GitHub. Please send me a message if you find something that does not work. The finished game looks like this:

Tetris

Tetris was created in 1984 by Alexey Pajitnov. The game requires players to rotate and move falling Tetris pieces. Players clear lines by completing horizontal rows of blocks without empty cells. But, if the pieces reach the top, the game is over!

Tetris is a great game to begin our journey in game development. It contains essential elements of games and is relatively easy to program. The tetrominos are a collection of four blocks, which makes graphics a bit easier than most games.

Project structure

It’s good to split the code up some in the project even if it’s not that big. The JavaScript is in four different files:

  • constants.js is where we put the configurations and rules of the game.
  • board.js is for board logic.
  • piece.js is for piece logic.
  • main.js has code to initialize the game and the overall game logic.
  • index.html the order of the scripts that we add at the end is essential.
  • styles.css all the beautifying styles are in here.
  • README.md markdown info file that is the first page in the repository.

Size and Style

The playing board consists of 10 columns and 20 rows. We are using these values often to loop through the board so we can add them to constants.js together with the size of the blocks:

I prefer using the canvas element for the graphics.

We can get the canvas element and its 2d context in main.js and use the constants to set the size:

By using scale, we can always give the size of the blocks as one (1) instead of having to calculate with BLOCK_SIZE everywhere, which simplifies our code.

Styling

It’s nice to have a bit of an 80’s feel to our game. Press Start 2P is a bitmap font based on the font design from the 1980s Namco arcade games. We can link to it in the <head> and add it to our styles:

The first section in styles.css is for the arcade-style font. Notice the use of CSS Grid and Flexbox for the layout:

With this, we have our game container styled and ready, awaiting code.

The Board

The board in Tetris consists of cells, which are either occupied or not. My first thought was to represent a cell with boolean values. But, we can do better by using numbers. We can represent an empty cell with 0, and the colors with numbers 1–7.

The next concept is representing the rows and columns of the game board. We can use an array of numbers to represent a row. And the board is an array of rows. In other words, a two dimensional (2D) array or what we call a matrix.

Let’s create a function in board.js that returns an empty board with all cells set to zero. The fill() method comes in handy here:

We can call this function in main.js when we press play:

By using console.table we see the representation of the board in numbers:

The X and Y coordinates represent the cells of the board. Now that we have the board, let’s take a look at the moving parts.

Tetrominos

A piece in Tetris is a shape consisting of four blocks that move as a unit. They are often called tetrominos and come in seven different patterns and colors. The names I, J, L, O, S, T, and Z are from the resemblance in their shape.

We represent the J tetromino as a matrix where the number two represents the colored cells. We add the row of zeros to get a center to rotate around:

[2, 0, 0],
[2, 2, 2],
[0, 0, 0];

The tetrominos spawn horizontally with J, L, and T spawning flat-side first.

We want the Piece class to know its position on the board, what color it has, and its shape. So to be able to draw itself on the board, it needs a reference to the canvas context.

For starters, we can hard-code the values of our piece:

To draw the tetromino on the board, we loop through all the cells of the shape. If the value in the cell is greater than zero, then we color that block.

The board keeps track of the tetromino on the board so we can create and paint it when we press the play button:

The blue J tetromino appears!

Next, let’s make magic happen through the keyboard.

Keyboard input

We need to connect the keyboard events to move the piece on the board. The move function changes the x or y variable of the current piece to change its position on the board.

move(p) {
this.x = p.x;
this.y = p.y;
}

Enums

Next, we map the keys to the key codes in constants.js. For this, it would be nice to have an enum.

Enum (enumeration) is a special type used to define collections of constants.

There are no built-in enums in JavaScript so let’s make one by creating an object with the values:

The const can be a bit misleading when working with objects and arrays and does not actually make them immutable. To achieve this, we can use Object.freeze(). A couple of gotchas here are:

  • For this to work properly, we need to use strict mode.
  • This only works one level down. In other words, if we have an array or object inside our object, then this does not freeze them.

Object literals

To match the key events to actions, we can use object literal lookups.

ES6 allows property keys of object literals to use expressions, making them computed property keys.

We need the brackets to get computed property names so that we can use our constants. This is a simplified example of how it works:

const X = 'x';
const a = { [X]: 5 };
console.log(a.x); // 5

We want to send in the current tetromino and return a copy of it together with the change in coordinates. For this, we can use the spread operator to get a shallow copy and then change the coordinates to our desired position.

In JavaScript, we can use shallow copying to copy primitive data types like numbers and strings. In our case, the coordinates are numbers. ES6 offers two shallow copy mechanisms: Object.assign() and the spread operator.

In other words, a lot is going on in this code snippet:

Which we can use with the code beneath to get the new state without mutating the original piece. It’s important because we don’t always want to move to a new position.

const p = this.moves[event.key](this.piece);

Next, we add an event listener that listens to keydown events:

Now we are listening to the keyboard events, and if we press left, right, or down arrows, then we can see the piece moving.

We have a movement! However, ghost pieces going through walls are not what we want.

Collision detection

Tetris would not be a particularly exciting game if all blocks could pass through each other, or if the walls and floor did not stop them. So instead of moving the tetromino, we’ll check for potential collisions first, and then only move the tetromino if it’s safe. We have a few different collisions to consider.

We have a collision when the tetromino:

  • hits the floor
  • moves left or right into a wall
  • hits a block on the board
  • rotates, and the new rotation hits a wall or block

We already defined the potential new position for the shape. Now we can add a check if this position is valid before we move to it. To check for collisions, we loop through all the spaces in the grid that the tetromino would take up in its potential new position.

The array method best suited for this is every(). With it, we can check whether all elements in the array pass the tests we provide. We calculate the coordinates of every block of the piece and check that it’s a valid position:

By using this method before we move, we make sure that we don’t move anywhere we shouldn’t:

if (this.valid(p)) {
this.piece.move(p);
}

Let’s try going outside the grid again.

No more ghosting!

Now that the floor stops the tetromino, we can add another move called the hard drop. Pressing space drops the tetromino until it collides with something. This is called a hard drop. We also need to add the new key mapping and move:

What’s next?

Rotation

Now we can move around, but it would not be any fun if we can’t rotate the piece. We need to rotate the tetrominos around their center.

It has been a while since I studied linear algebra in school. But, to rotate clockwise goes something like this:

Two reflections can accomplish a rotation by 90 degrees at a 45-degree angle so you can take the transpose of the matrix and then multiply it by the permutation matrix that reverses the order of the columns.

And in JavaScript:

We can add a function that rotates the shape. Earlier, we used the spread operator to clone the coordinates. In this case, we are working with a multiple level array, but the spread operator only copies one level deep. The rest is copied by reference.

I’m instead using JSON.parse and JSON.stringify. The stringify() method converts the matrix to a JSON string. The parse() method parses the JSON string, constructing our matrix back again to a clone.

Then we add a new state for ArrowUp in board.js.

[KEY.UP]: (p) => this.rotate(p)

Now we rotate!

Randomize Tetromino

To be able to get different kinds of pieces, we need to add a bit of randomization to our code.

Following the Super Rotation System, we can take the first position of the pieces and add them to our constants together with the colors.

We need to randomize the index of one of these to pick one piece. To get a random number, we create a function that uses the length of the array.

randomizeTetrominoType(noOfTypes) {
return Math.floor(Math.random() * noOfTypes);
}

With this method we can get a random tetromino type when we spawn and then set the color and shape from it:

const typeId = this.randomizeTetrominoType(COLORS.length);
this.shape = SHAPES[typeId];
this.color = COLORS[typeId];

If we press play the page shows pieces with different shapes and colors.

Game Loop

Almost all games have one main function that keeps the game running even when the user isn’t doing anything. This cycle of running the same core function over and over again is called the game loop. In our game, we need a game loop that moves the tetrominos down the screen.

RequestAnimationFrame

To create our game loop, we can use requestAnimationFrame. It tells the browser that we want to animate, and it should call a function to update an animation before the next repaint. In other words, we tell the browser: “Next time you paint on the screen, also run this function because I want to paint something too.”

“Animation is not the art of drawings that move but the art of movements that are drawn.” — Norman McLaren

The way to animate with window.requestAnimationFrame() is to create a function that paints a frame and then re-schedules itself. If we use it inside a class (we don’t in our case), we need to bind the call to this, or it has the window object as its context. Since it doesn't contain the animate function, we get an error.

animate() {
this.piece.draw();
requestAnimationFrame(this.animate.bind(this));
}

We can remove all our previous calls to draw() and instead call animate() from the play() function to start the animation. If we try our game, it should still run like before.

Timer

Next, we need a timer. Every time frame, we drop the tetromino. There is an example on the MDN page that we can modify to our needs.

We start by creating an object with the info we need:

time = { start: 0, elapsed: 0, level: 1000 };

In the game loop, we update our game state based on the time interval and then draw the result.

We have animation!

Next, let’s look at what happens when we reach the bottom.

Freeze

When we can’t move down anymore, we freeze the piece and spawn a new one. Let’s start by defining freeze(). This function merges the tetromino blocks to the board:

We can’t see anything yet, but by logging the representation of the board, we can see that the shape is on the board.

Let’s add a function that draws the board:

Now the draw function looks like this:

draw() {
this.piece.draw();
this.drawBoard();
}

If we run the game, we can see that the pieces are showing up.

Now that we are freezing the pieces, we need to add new collision detection. This time we have to make sure that we don’t collide with frozen tetrominos on the board. We can do this by checking that the cell is zero. Add this to the valid method and send in the board as an argument:

board[p.y + y][p.x + x] === 0;

Now that we are adding pieces to the board, it quickly gets crowded. We should do something about that.

Line clear

To last longer, we need to assemble the tetrominos in rows of blocks that span the entire row, resulting in a line clear. When you do so, the row disappears, causing the ones above it to settle.

Detecting formed lines is as easy as checking if it has any zeros:

We can add a call to this clearLines() function after the freeze() call. We can try playing and hopefully see the rows getting cleared.

Score

To get a bit more excitement, we need to keep score. From the Tetris guideline we get these values:

To keep track of the game progress, we add an accountValues object with the score and lines. When any of these values changes, we want to change it on the screen. We add a generic function that gets the element from the HTML and changes its textContext to the value provided.

To act on changes on the account object, we can create a Proxy object and run the code to update the screen in the set method. We send in the accountValues object to the proxy because this is the object we want to have custom behaviors on:

Now every time we call properties on the proxy account, we call updateAccount() and update the DOM. Let’s add the points for soft and hard drops in our event handler:

Now for the line clear points. Depending on the number of lines, we get the defined points:

For this to work, we need to add a bit of logic to count how many lines we clear:

If we try playing now, we can see that we are increasing our score. What we need to keep in mind is that whenever we want something to show up on the screen, we need to go through the proxy instead of directly to the account object.

Levels

When we get better at Tetris, the speed we start on gets too easy. And too easy means boring. So we need to increase the level of difficulty. We do this by decreasing the interval speed in our game loop.

We can also show the player which level they are currently on. The logic of keeping track and showing levels and lines is the same as for points. We initialize a value for them, and when we start a new game, we have to reset them.

We can add it to the account object:

let accountValues = {
score: 0,
lines: 0,
level: 0
}

The initialization of the game can go in a function that we call from play():

function resetGame() {
account.score = 0;
account.lines = 0;
account.level = 0;
board = this.getEmptyBoard();
}

With increasing levels comes more points for line clears. We multiply the points with the current level and add one since we start on level zero.

(account.level + 1) * lineClearPoints;

The next level is reached when the lines are cleared as configured. We also need to update the speed of the level.

Now if we play and clear ten lines we see the level increase and the points double. And of course the game starts moving a bit faster.

Game Over

If you play for a while, you notice that the tetrominos don’t stop falling. We need to know when to end the game.

After we drop we can check if we are still on row 0 and in that case, we stop the game by exiting the game loop function:

if (this.piece.y === 0) {
this.gameOver();
return;
}

Before we exit, we cancel the previously scheduled animation frame request with cancelAnimationFrame. And, we show a message to the user.

Next tetromino

Let’s add one last thing, the next tetromino. We can add another canvas for this:

<canvas id="next" class=”next”></canvas>

Next, we do as we did for our first canvas:

const canvasNext = document.getElementById('next');
const ctxNext = canvasNext.getContext('2d');
// Size canvas for four blocks.
ctxNext.canvas.width = 4 * BLOCK_SIZE;
ctxNext.canvas.height = 4 * BLOCK_SIZE;
ctxNext.scale(BLOCK_SIZE, BLOCK_SIZE);

We have to change the logic a bit in the drop function. Instead of creating a new piece we set it to the next and instead create a new next piece:

this.piece = this.next;
this.next = new Piece(this.ctx);
this.next.drawNext(this.ctxNext);

Now that we see which piece is coming next, we can be a bit more strategic.

Conclusion

Today we learned about the basics in game development and how we can use Canvas for graphics. I also wanted this project to be a fun way of learning modern JavaScript. I hope you enjoyed the article and learned something new for your JavaScript toolbox.

And now that we have taken our first steps into game development, what game do we do next?

Thank you, Tim Deschryver for being the sounding board of my Tetris journey.

Resources

Michael Karén

Written by

React by day, Angular by night. Senior Consultant jProfessionals. Writer Angular In Depth.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade