DEV Community

Cover image for Step by step Tetris on ES6 and Canvas
Oinak
Oinak

Posted on

Step by step Tetris on ES6 and Canvas

Some time ago I wrote a post called a very classy snake, inspired by a YouTube video and to try to touch on ES6, canvas and game programming basics.

Shortly afterwards, as it usually does, youtube started suggesting similar videos, and I found myself looking at this tetris on C++ video. C++ is not my thing lately, but I wanted an excuse to play some more with ES6 and canvas, so I though, why not combine the teachings from both videos to create a canvas tetris?

Both

  1. Boilerplate
  2. Playing Field
  3. A single piece
  4. Movement and collision
  5. Touchdown and new piece
  6. Clearing lines and scoring

1. Boilerplate

In the begining, I just copied the html from the snake game, changing just the canvas dimensions to the proportions of the tetris pit (taken from the research the pal from the video did, and by research I mean he counted the squares on a GameBoy, so I did not have to :-)

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
    <title>repl.it</title>
    <link href="style.css" rel="stylesheet" type="text/css" />
  </head>
  <body>
    <canvas id='field' width='240' height='360'>Loading...</div>
    <script src='script.js'></script> 
    <script>
      window.onload = () => { let game = new Game({canvasId: 'field'}); };  
    </script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Once we have this, we will copy over the skeleton of a game. What do I mean from skeleton. Most classic games have a very similar scaffolding, this is:

  1. Capture user input
  2. Calculate the new game state
  3. Redraw the game GUI based on the new state

This is usually called The game loop because it was, you guessed it, implemented into an infite loop, broken only by win and loss conditions.

As we are in javascript, we are making a slightly more asynchronous version of this, reading user inputs out of events, and executing the state recalculation and the screen redrawing with a setInterval.

// jshint esnext:true

class Game {
  constructor({ canvasId }){
    // this.field = new Field({...});
    this.init();
  }

  init(){
    addEventListener('keydown', (e) => { this.userInput(e) } ); // User input
    setInterval( () => { this.game() }, 1000 / 8);              // Game loop 
  }

  userInput(e){
    // nothing here yet
  }

  game(){
     // nothing here yet
  }
}
Enter fullscreen mode Exit fullscreen mode

Once you have this, you only have to fill in the gaps, and it is as easy as drawing an owl:

Drawing an owl

2. Playing field

Now let's go for something you will be able to see at last. To that end, there are two bits we will rescue from the snake game:

  • First, the canvas initialisation code:
    let canvas = document.getElementById(canvasId);
    this.context = canvas.getContext('2d');
Enter fullscreen mode Exit fullscreen mode
  • Then the code to draw a single square on our imaginary grid:
  // Draw a single tile (using canvas primitives)
  drawTile(x, y, color){
    this.context.fillStyle = color;
    this.context.fillRect(
      x * this.size,  // x tiles to the rigth
      y * this.size,  // y tiles down
      this.size - 1,  // almost as wide as a tile
      this.size - 1); // almost as tall
  }
Enter fullscreen mode Exit fullscreen mode

We are using the fillRect primitive, it can only draw rectangles, but our Tetris game will have a fat pixels aesthetic, so that will be enough for us.

We will create a new class, in charge of holding the game state and drawing the background screen.

class Field{
  constructor({width, height, size, canvasId}){
    this.width = width;   // number of tiles sideways 
    this.height = height; // number of tiles downward
    this.size = size;     // size of a tile in pixels

    this.init(canvasId);  // initialize the field
  }

  init(canvasId){
    // first, set up the canvas context:
    let canvas = document.getElementById(canvasId);
    this.context = canvas.getContext('2d');

    // then set up the grid
    this.initTileGrid();
  }

  // Create the original grid of tiles composed of void and walls
  initTileGrid(){
    this.tiles = []; // a list of columns
    for(let x = 0; x < this.width; x += 1) {
      this.tiles[x] = []; // a single column
      for(let y = 0; y < this.height; y +=1 ) {
        this.tiles[x][y] = this.isWall(x, y) ? 'w' : ' ';
      }
    }
  }

  // Are these x,y coordinates part of a wall?
  // use for drawing and for wall-collision detection  
  isWall(x, y){
    return (x === 0 ||          // left wall
      x === (this.width - 1) || // right wall
      y === (this.height-1));   // floor
  }

  // For every tile in the grid, drwa a square of the apropriate color
  draw(){
    for(let x = 0; x < this.width; x += 1) {
      for(let y = 0; y < this.height; y +=1 ) {
        this.drawTile(x, y, this.colorFor(this.tiles[x][y]));
      }
    }    
  }

  // Draw a single tile (using canvas primitives)
  drawTile(x, y, color){
    this.context.fillStyle = color;
    this.context.fillRect(
      x * this.size,  // x tiles to the right
      y * this.size,  // y tiles down
      this.size - 1,  // almost as wide as a tile
      this.size - 1); // almost as tall
  }

  // Relate grid cell content constants with tile colors
  colorFor(content){
    let color = { w: 'grey' }[content];
    return color || 'black';
  }
}
Enter fullscreen mode Exit fullscreen mode

This is ready to roll, but the Game class is not yet referring to it, so we need to do this small changes:

class Game {
  constructor({ canvasId }){
    this.field = new Field({
      width: 12,         // number of tiles to the right
      height: 18,        // number of tiles downwards
      size: 20,          // side of the tile in pixels
      canvasId: canvasId // id of the cavnas tag
    });
    this.init();
  }

  // ... the rest remains unchanged
}
Enter fullscreen mode Exit fullscreen mode

Once you have, you should be able to see something like this:

The playing field

Things to observe:

We are using cartesian (that is 2D) coordinates, but we are starting the count at the top-left corner of the canvas, because that is what canvas functions do, and it saves us from some conversion math.

A single piece

A tetris piece or, as I learnt in the video, a tetronimo can be represented as a 4x4 binary matrix of full and empty spaces.

// If you squint you see the 'L' piece:
[[' ','L',' ',' '],
 [' ','L',' ',' '],
 [' ','L','L',' '],
 [' ',' ',' ',' ']]
Enter fullscreen mode Exit fullscreen mode

But if we concatenate those 4 lists it can be simplified as a list:

[' ','L',' ',' ',' ','L',' ',' ',' ','L','L',' ',' ',' ',' ',' ']
Enter fullscreen mode Exit fullscreen mode

where you use (x,y) => { list[4*y + x] } to see each position as a cell.
And javascript being weakly typed allows you to do this with a string as well:

' L   L   LL     '
Enter fullscreen mode Exit fullscreen mode

The video uses A,B,C... letters to refer to (and draw) the pieces, I prefer to use the letters that remind me most of the tetromino's shape, thus the 'L' here.

Pieces have three main motions, sideways, downward and rotation. Sideways and downward motions can be easily figured adding units to the coordinates, so we will deal first with the more complex one, rotation.

Rotation:

Let's draw the numbered positions from our strings in the position they will have in the 4x4 grid, and then, figure out (or copy from the video ;-) the math to have a matrix rotation:

var grid = [
  0,  1,  2,  3,
  4,  5,  6,  7,
  8,  9,  10, 11,
  12, 13, 14, 15
];

var newGrid = [];
for (let i0 = 0; i0 < 16; i0++){
    // convert to x/y
    let x0 = i0 % 4;
    let y0 = Math.floor(i0 / 4);

    // find new x/y
    let x1 = 4 - y0 - 1;
    let y1 = x0;

    //convert back to index
    var i1 = y1 * 4 + x1;
    newGrid[i1] = grid[i0];
}

console.log(newGrid);
// [12, 8,  4, 0,
//  13, 9,  5, 1,
//  14, 10, 6, 2,
//  15, 11, 7, 3]
Enter fullscreen mode Exit fullscreen mode

If you do this with a piece represented as a string you get:

var grid = '  I   I   I   I '; 
// Visual help: this is the above as a 4x4 grid:
// [" ", " ", "I", " ",
//  " ", " ", "I", " ",
//  " ", " ", "I", " ",
//  " ", " ", "I", " "]

var newGrid = [];
for (let i0 = 0; i0 < 16; i0++){
    // convert to x/y
    let x0 = i0 % 4;
    let y0 = Math.floor(i0 / 4);

    // find new x/y
    let x1 = 4 - y0 - 1;
    let y1 = x0;

    //convert back to index
    var i1 = y1 * 4 + x1;
    newGrid[i1] = grid[i0];
}

console.log(newGrid);
// [" ", " ", " ", " ",
//  " ", " ", " ", " ",
//  "I", "I", "I", "I",
//  " ", " ", " ", " "]

console.log(newGrid.join(''));
// "        IIII    "
Enter fullscreen mode Exit fullscreen mode

Let's build a new Piece class with this logic in it:

class Piece{
  constructor({variant, x, y}){
    this.x = x;
    this.y = y;
    this.contents = this.variants()[variant];
  }
  variants(){
    return { // 16 chars = 4x4 char grid
      i: '  i   i   i   i ', // 4x1 column
      t: '  t  tt   t     ', // short 'T' shape
      l: ' l   l   ll     ', // L (short arm right)
      j: '  j   j  jj     ', // J (sort arm left)
      o: '     oo  oo     ', // square, centered or rotation would displace
      s: '  ss ss         ', // step climbing right
      z: ' zz   zz        '  // step climbing left
    };
  }

  rotate(){
    let newGrid = [];
    for (let i0 = 0; i0 < 16; i0++){
      // convert to x/y
      let x0 = i0 % 4;
      let y0 = Math.floor(i0 / 4);

      // find new x/y 
      let x1 = 4 - y0 - 1;
      let y1 = x0;

      //convert back to index
      var i1 = y1 * 4 + x1;
      newGrid[i1] = this.contents[i0];
    }
    this.contents = newGrid.join('');
  }

  reverse(){ // 1/4 left = 3/4 right
    rotate();
    rotate();
    rotate();
  }

  toString(){
    return [this.contents.slice(0 , 4),
     this.contents.slice(4 , 8),
     this.contents.slice(8 , 12),
     this.contents.slice(12, 16)].join("\n"); 
  }
}

let p = new Piece({variant: 'l', x: 5, y: 0})
console.log(`----\n${p.toString()}\n----`);
p.rotate();
console.log(`----\n${p.toString()}\n----`);
p.rotate();
console.log(`----\n${p.toString()}\n----`);
p.rotate();
console.log(`----\n${p.toString()}\n----`);
Enter fullscreen mode Exit fullscreen mode

If you run this code, you get this output:


"----
 L  
 L  
 LL 

----"
"----

 LLL
 L  

----"
"----

 LL 
  L 
  L 
---------"
"----

  L 
LLL 

---------"
Enter fullscreen mode Exit fullscreen mode

Can you see the 'L' piece rotating clockwise?

The .toString() method is not needed for the game logic but it is useful for debugging, feel free to leave it there if it helps you.

Next step: draw it onto the canvas. The drawing logic is on the Field so we are going to add a method to draw the current piece.

Changes to Field

Initialize the current Piece:

  init(canvasId){
    // (...) the rest of the method unchanged (...)
    this.currentPiece = new Piece({x: 4,y: 0});
  }
Enter fullscreen mode Exit fullscreen mode

The draw method:

  // For every tile in the grid, draw a square of the apropriate color
  draw(){
    // (...) the rest of the method unchanged (...)
    this.drawPiece(this.currentPiece);
  }
Enter fullscreen mode Exit fullscreen mode

And a new drawPiece function:

  drawPiece(piece){
    let tile = ' ';
    for(let x = 0; x < 4; x += 1){
      for(let y = 0; y < 4; y += 1){
        tile = piece.at(x,y) 
        if (tile !== ' '){
          this.drawTile(piece.x + x,
                        piece.y + y,
                        this.colorFor(tile));
        } // non empty
      } // column tiles
    } // piece columns
  }
Enter fullscreen mode Exit fullscreen mode

As you see, we are still using the colorFor method to choose the color of the tiles, so now we need a coor for every piece, so we go to the Tetris page on wikipedia to choose them:

Tetrominoes

  // Relate grid cell content constants with tile colors
  colorFor(content){
    let color = {
      w: 'grey',
      i: 'lightblue',
      t: 'lightgreen',
      l: 'orange',
      j: 'blue',
      o: 'yellow',
      s: 'lime',
      z: 'red'
    }[content];
    return color || 'black';
  }
Enter fullscreen mode Exit fullscreen mode

The final version of the Piece class has the ability to randomly choose a variant upon initialisation:


class Piece{
  constructor({x, y}){
    this.x = x;
    this.y = y;
    this.contents = this.chooseVariant();
  }

  // changed from variants to this, with the random logic
  chooseVariant(){
    // https://stackoverflow.com/questions/2532218/pick-random-property-from-a-javascript-object
    let variants = {
      i: '  i   i   i   i ', // 16 chars = 4x4 char grid
      t: '  t  tt   t     ',
      l: ' l   l   ll     ',
      j: '  j   j  jj     ',
      o: '     oo  oo     ', // centered or rotation would displace
      s: '  ss ss         ',
      z: ' zz   zz        '
    };
    let keys = Object.keys(variants);
    return variants[keys[ keys.length * Math.random() << 0]]; // << 0 is shorcut for Math.round
  }

  at(x, y){
    return this.contents[(y * 4) + (x % 4)];
  }

  rotate(){
    let newGrid = [];
    for (let i0 = 0; i0 < 16; i0++){
      // convert to x/y
      let x0 = i0 % 4;
      let y0 = Math.floor(i0 / 4);

      // find new x/y 
      let x1 = 4 - y0 - 1;
      let y1 = x0;

      // convert back to index
      var i1 = y1 * 4 + x1;
      newGrid[i1] = this.contents[i0];
    }
    this.contents = newGrid.join('');
  }

  reverse(){ // 1/4 left = 3/4 right
    rotate();
    rotate();
    rotate();
  }
}
Enter fullscreen mode Exit fullscreen mode

Once you have this code in place, you should be able to see something like this:

Alt Text

Bear in mind it probably chose a different tetromino for you, and will choose a random one every time you run the code.

Movement and collision

Now that we have a Playing field, and a piece on it, it is time to get interactive, so we are going to listen to player input and react to it.

Also we have walls, and they would not be worth such name it stuff just went through, right?.

So this is the strategy for this section:

  1. Read user input
  2. Create a displaced or rotated version of the piece
  3. Check if the virtual piece fits (does not collide)
    • If it fits, it becomes the current piece
    • If it does not, movement gets blocked (for now, we will see what else later)

Read user input

I am going to be totally lazy here and copy over from the snake game:

  // on Game class
  userInput(event){
    const arrows = { left: 37, up: 38, right: 39, down: 40};
    const actions = {
      [arrows.left]:  'moveLeft',
      [arrows.up]:    'rotate',
      [arrows.right]: 'moveRight',
      [arrows.down]:  'moveDown'
    }
    if (actions[event.keyCode] !== undefined){ // ignore unmapped keys
      this.field.handle(actions[event.keyCode]);
    }
  }
Enter fullscreen mode Exit fullscreen mode

Create the virtual piece (we make it accept contents for this)

There is no deep cloning out of the box on ES6 so we just initialise a new Piece with the same properties and then apply the motion indicated by the user's input:

Piece class:

class Piece{
  constructor(options = {}) {
    const defaults = { x: 0 , y: 0, contents: null };
    Object.assign(this, defaults, options);

    // accept contents for piece copying, select random for new pieces:
    this.contents = this.contents || this.chooseVariant();
  }

  chooseVariant(){
    // unmodified
  }

  //// User actions:
  moveRight(){
    this.x += 1;
  }

  moveLeft(){
    this.x -= 1;
  }

  moveDown(){
    this.y += 1;
  }

  rotate(){ 
    // unmodified
  }

  // return a copy of the object:
  dup(){
    return new Piece({x: this.x, y: this.y, contents: this.contents});
  }

Enter fullscreen mode Exit fullscreen mode

And now the handle method in the Field class:

  handle(action){
    // make a copy of the existing piece:
    let newPiece = this.currentPiece.dup();

    // effect the user selected change on the new piece:
    newPiece[action]();

    // temporal, to see the effect:
    this.currentPiece = newPiece;
    this.draw();
  }
Enter fullscreen mode Exit fullscreen mode

After this, you should be able to move your piece sideways and downwards, but alas, it does not stop on walls.

Detect collision

This handle function is not very smart, so we are going to add a check to see if a piece can fit in the place we are trying to send it to, before effectively doing the move:

  handle(action){
    // make a copy of the existing piece:
    let newPiece = this.currentPiece.dup();

    newPiece[action](); // move or rotate according to userInput

    if (this.canFit(newPiece)){
      this.currentPiece = newPiece;
    } else {
      console.log('colision!');
      // touchDown?
    }
    this.draw();
  }
Enter fullscreen mode Exit fullscreen mode

This is very similar to what we have before, but now, how do we know if the piece can indeed fit. We don't need 4x4 tiles free because tetronimos do not occuppy their full grid, to achieve the puzzle effect we only want to check if every tile on the piece grid is either empty on the piece or on the field, in either case there is no collision. Collosions happen when a non-empty cell from the piece is atop a non-empty cell of the field.

Let's translate all this jargon to code:

  canFit(piece){ // for every overlap tile between the piece and the field:
    for(let x = 0; x < 4; x++){
      for(let y = 0; y < 4; y++){
        if (piece.at(x, y) !== ' ' &&                      // piece is not empty
            this.tiles[piece.x + x][piece.y + y] != ' ' ){ // field is not empty
          return false; //there is collision
        }
      }
    }
    return true; // if there are no collisions, it can fit
  }
Enter fullscreen mode Exit fullscreen mode

After this, you can still move your pieces, but no longer overlap them with the walls or floor. The console.log('collision!') will be executed every time you go over a wall or the floor, but the piece won't move.

Alt Text

Before going on, I noticed that the rotations had a strange symmetry. This is, the pieces rotating around different axis from what they do on the original game. First I fixed this on the square, going:

From this:    To this:
'oo  '        '    '
'oo  '        ' oo '
'    '        ' oo '
'    '        '    '
Enter fullscreen mode Exit fullscreen mode

But that trick did not work for every piece. So I dug deeper, and I noticed I felt uncomfortable about the literal 4's sprinkled all over the code, so I thought: what if different pieces are different sizes?

So I made these changes to Piece:

  • Added a length and a side getters to Piece, to use instead of 16 and 4 throughout the code.
  • Edited every method using the Piece's length or side to use the new attributes.
  • Once everything was working again, I changed the pieces strings to the smallest possible grids with the better symmetry I could get.

Here are the changed methods in piece:

class Piece{
  constructor(options = {}) {
    const defaults = { x: 0 , y: 0, contents: null };
    Object.assign(this, defaults, options);
    this.contents = this.contents || this.chooseVariant();
  }

  chooseVariant(){
    // https://stackoverflow.com/questions/2532218/pick-random-property-from-a-javascript-object
    let variants = {
      i: '  i '+
         '  i '+
         '  i '+
         '  i ', // 16 chars = 4x4 char grid
      t: ' t '+ // 3x3
         'ttt'+
         '   ',
      l: 'l  '+
         'l  '+
         'll ',
      j: '  j'+
         '  j'+
         ' jj',
      o: 'oo'+ // 2x2
         'oo',
      s: ' ss'+
         'ss '+
         '   ',
      z: 'zz '+
         ' zz'+
         '   '
    };
    let keys = Object.keys(variants);
    this.variant = this.variant  || (keys[ keys.length * Math.random() << 0]);
    return variants[this.variant];
  }

  get length(){
    return this.contents.length;
  }

  get side(){
    return Math.sqrt(this.length);
  }

  at(x, y){
    return this.contents[(y * this.side + (x % this.side )) ];
  }

  // ... moveRight/Left/Down unmodified

  rotate(){
    let newGrid = [];
    for (let i0 = 0; i0 < this.length; i0++){
      // convert to x/y
      let x0 = i0 % this.side;
      let y0 = Math.floor(i0 / this.side);

      // find new x/y 
      let x1 = this.side - y0 - 1;
      let y1 = x0;

      // convert back to index
      var i1 = y1 * this.side + x1;
      newGrid[i1] = this.contents[i0];
    }
    this.contents = newGrid.join('');
  }

Enter fullscreen mode Exit fullscreen mode

And here you have the changed methods outside of Piece, which are the two Field methods that received a Piece as argument, canFit and drawPiece:

// Field class
  canFit(piece){ // for every overlap tile between the piece and the field:
    for(let x = 0; x < piece.side; x++){
      for(let y = 0; y < piece.side; y++){
        if (piece.at(x, y) !== ' ' &&                      // piece is not empty
            this.tiles[piece.x + x][piece.y + y] != ' ' ){ // field is not empty
          return false; //there is collision
        }
      }
    }
    return true; // if there are no collisions, it can fit
  }

  //...

  drawPiece(piece){
    let tile = ' ';
    for(let x = 0; x < piece.side; x += 1){
      for(let y = 0; y < piece.side; y += 1){
        tile = piece.at(x,y); 
        if (tile !== ' '){
          this.drawTile(piece.x + x,
                        piece.y + y,
                        this.colorFor(tile));
        } // non empty
      } // column tiles
    } // piece columns
  }
Enter fullscreen mode Exit fullscreen mode

Once you have this, you have the original rotation on all pieces but the 4x1 column.

Time to start piling pieces and clearing lines now.

If you read all this, first of all, thank you very much! I hope you are having so much fun reading and, I hope, following along, as I had figuring out how to explain it.

Second, you might be curious how does this continue, but if you want to know that, you will have to jump to Rocknrollesque's post #TODO: review the link.

I created my dev.to account inspired by her, and I wanted to return the favour, so I challenged her to finish this post, so that she had to create a dev.to blog of her own.

So go now to find about:

Touchdown and new piece

and

Clearing lines and scoring

Discussion (0)