Learning Modern JavaScript with Tetris

Learning Modern JavaScript with Tetris

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. Future developments of the game will go into the master branch while the blog branch will stay as is.

The finished game looks like this:

Translations:

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:

const COLS = 10;
const ROWS = 20;
const BLOCK_SIZE = 30;

I prefer using the canvas element for the graphics:

<div class="grid">
  <canvas id="board" class="game-board"></canvas>
  <div class="right-column">
    <div>
      <h1>TETRIS</h1>
      <p>Score: <span id="score">0</span></p>
      <p>Lines: <span id="lines">0</span></p>
      <p>Level: <span id="level">0</span></p>
      <canvas id="next" class="next"></canvas>
    </div>
    <button onclick="play()" class="play-button">Play</button>
  </div>
</div>

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

const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');

// Calculate size of canvas from constants.
ctx.canvas.width = COLS * BLOCK_SIZE;
ctx.canvas.height = ROWS * BLOCK_SIZE;

// Scale blocks
ctx.scale(BLOCK_SIZE, BLOCK_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 &lt;head&gt; and add it to our styles:

<link 
  href="https://fonts.googleapis.com/css?family=Press+Start+2P" 
  rel="stylesheet"
/>

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

* {
  font-family: 'Press Start 2P', cursive;
}

.grid {
  display: grid;
  grid-template-columns: 320px 200px;
}

.right-column {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}

.game-board {
  border: solid 2px;
}

.play-button {
  background-color: #4caf50;
  font-size: 16px;
  padding: 15px 30px;
  cursor: pointer;
}

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.

The board is a good candidate for a class. We probably wan’t to create a new Board when starting a new game. If you wan’t to learn more about classes I have written a short article about them:

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:

class Board {

  // Reset the board when we start a new game.
  reset() {
    this.grid = this.getEmptyBoard();
  }

  // Get matrix filled with zeros.
  getEmptyBoard() {
    return Array.from(
      {length: ROWS}, () => Array(COLS).fill(0)
    );
  }
}

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

let board = new Board();

function play() {  
  board.reset();  
  console.table(board.grid);  
}

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 in the constructor of the Piece class:

class Piece {  
  constructor(ctx) {
    this.ctx = ctx;
    this.color = 'blue';
    this.shape = [
      [2, 0, 0], 
      [2, 2, 2], 
      [0, 0, 0]
    ];

    // Starting position.
    this.x = 3;
    this.y = 0;
  }
}

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:

draw() {
  this.ctx.fillStyle = this.color;
  this.shape.forEach((row, y) => {
    row.forEach((value, x) => {
      // this.x, this.y gives the left upper position of the shape
      // x, y gives the position of the block in the shape
      // this.x + x is then the position of the block on the board
      if (value > 0) {
        this.ctx.fillRect(this.x + x, this.y + y, 1, 1);
      }
    });
  });
}

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

function play() {
  board.reset();
  let piece = new Piece(ctx);
  piece.draw();

  board.piece = piece;
}

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:

const KEY = {
  LEFT: 37,
  RIGHT: 39,
  DOWN: 40
}
Object.freeze(KEY);

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:

const moves = {
  [KEY.LEFT]:  p => ({ ...p, x: p.x - 1 }),
  [KEY.RIGHT]: p => ({ ...p, x: p.x + 1 }),
  [KEY.DOWN]:    p => ({ ...p, y: p.y + 1 })
};

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:

document.addEventListener('keydown', event => {
  if (moves[event.keyCode]) {  
    // Stop the event from bubbling.
    event.preventDefault();

    // Get new state of piece
    let p = moves[event.keyCode](board.piece);

    if (board.valid(p)) {    
      // If the move is valid, move the piece.
      board.piece.move(p);

      // Clear old position before drawing.
      ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 

      board.piece.draw();
    }
  }
});

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:

valid(p) {
  return p.shape.every((row, dy) => {
    return row.every((value, dx) => {
      let x = p.x + dx;
      let y = p.y + dy;
      return (
        this.isEmpty(value) ||
       (this.insideWalls(x) &&
        this.aboveFloor(y)
      );
    });
  });
}

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:

const KEY = {  
  SPACE: 32,
  // ...
}

moves = {  
  [KEY.SPACE]: p => ({ ...p, y: p.y + 1 })
  // ...
};

// In EventListener
if (event.keyCode === KEY.SPACE) {
  // Hard drop
  while (board.valid(p)) {
    board.piece.move(p);   
    p = moves[KEY.DOWN](board.piece);
  }
}

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:

// Transpose matrix, p is the Piece.
for (let y = 0; y < p.shape.length; ++y) {
  for (let x = 0; x < y; ++x) {
    [p.shape[x][y], p.shape[y][x]] = 
    [p.shape[y][x], p.shape[x][y]];
  }
}

// Reverse the order of the columns.
p.shape.forEach(row => row.reverse());

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:

rotate(p){
  // Clone with JSON for immutability
  let clone = JSON.parse(JSON.stringify(p));

  // Do algorithm

  return 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:

const COLORS = [  
  'cyan',
  'blue',
  'orange',
  'yellow',
  'green',
  'purple',
  'red'
];

const SHAPES = [  
  [
    [0, 0, 0, 0], 
    [1, 1, 1, 1],
    [0, 0, 0, 0], 
    [0, 0, 0, 0]
  ], 
  [
    [2, 0, 0],
    [2, 2, 2],
    [0, 0, 0]
  ],
  // And so on
];

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:

function animate(now = 0) {
  // Update elapsed time.  
  time.elapsed = now - time.start;

  // If elapsed time has passed time for current level  
  if (time.elapsed > time.level) {

    // Restart counting from now
    time.start = now;   

    this.drop();  
  }

  // Clear board before drawing new state.
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 

  board.draw();  
  requestId = requestAnimationFrame(animate);
}

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:

freeze() {
  this.piece.shape.forEach((row, y) => {
    row.forEach((value, x) => {
      if (value > 0) {
        this.grid[y + this.piece.y][x + this.piece.x] = value;
      }
    });
  });
}

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:

drawBoard() {
  this.grid.forEach((row, y) => {
    row.forEach((value, x) => {
      if (value > 0) {
        this.ctx.fillStyle = COLORS[value];
        this.ctx.fillRect(x, y, 1, 1);
      }
    });
  });
}

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:

this.grid.forEach((row, y) => {

  // If every value is greater than 0.
  if (row.every(value => value > 0)) {

    // Remove the row.
    this.grid.splice(y, 1);

    // Add zero filled row at the top. 
    this.grid.unshift(Array(COLS).fill(0));
  } 
});

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:

const POINTS = {
  SINGLE: 100,
  DOUBLE: 300,
  TRIPLE: 500,
  TETRIS: 800,
  SOFT_DROP: 1,
  HARD_DROP: 2
}
Object.freeze(POINTS);

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. If you wan’t to learn the basics of Proxy I have a short article on it you can read:

We send in the accountValues object to the proxy because this is the object we want to have custom behaviors on:

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

function updateAccount(key, value) {
  let element = document.getElementById(key);
  if (element) {
    element.textContent = value;
  }
}

let account = new Proxy(accountValues, {
  set: (target, key, value) => {
    target[key] = value;
    updateAccount(key, value);
    return true;
  }
}

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:

if (event.keyCode === KEY.SPACE) {
  while (board.valid(p)) {
    account.score += POINTS.HARD_DROP;
    board.piece.move(p);
    p = moves[KEY.DOWN](board.piece);
  }
} else if (board.valid(p)) {
  board.piece.move(p);
  if (event.keyCode === KEY.DOWN) {
    account.score += POINTS.SOFT_DROP;
  }
}****

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

getLineClearPoints(lines) {  
  return lines === 1 ? Points.SINGLE :
         lines === 2 ? Points.DOUBLE :  
         lines === 3 ? Points.TRIPLE :     
         lines === 4 ? Points.TETRIS : 
         0;
}

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

clearLines() {
  let lines = 0;
  this.board.forEach((row, y) => {    
    if (row.every(value => value !== 0)) {      
      lines++; // Increase for cleared line
      this.board.splice(y, 1); 
      this.board.unshift(Array(COLS).fill(0));
    }  
  });  
  if (lines > 0) {    
    // Add points if we cleared some lines
    account.score += this.getLineClearPoints(lines);  
  }
}

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:

const LINES_PER_LEVEL = 10;

const LEVEL = {
  0: 800,
  1: 720,
  2: 630,
  3: 550,
  // ...
}

Object.freeze(LEVEL);

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:

if (lines > 0) {
  // Calculate points from cleared lines and level.

  account.score += this.getLinesClearedPoints(lines, this.level);
  account.lines += lines;

  // If we have reached the lines for next level
  if (account.lines >= LINES_PER_LEVEL) {

    // Goto next level
    account.level++;

    // Remove lines so we start working for the next level
    account.lines -= LINES_PER_LEVEL;

    // Increase speed of game.
    time.level = Level[account.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:

function gameOver() {
  cancelAnimationFrame(requestId);
  this.ctx.fillStyle = 'black';
  this.ctx.fillRect(1, 3, 8, 1.2);
  this.ctx.font = '1px Arial';
  this.ctx.fillStyle = 'red';
  this.ctx.fillText('GAME OVER', 1.8, 4);
}

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?

Check out how to add high scores:

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

Resources

Did you find this article valuable?

Support Michael Karén by becoming a sponsor. Any amount is appreciated!