A Tetris clone in TypeScript

2023-10-03

When I was learning how to program I made several recreations of 2D games such as "Space Invaders", "Pong" and "Snake". One game I tried to make and failed at was Tetris.

What I found most difficult at the time was rotating the pieces. I see tetris as my white whale... or the one that got away.

But it's 16 years later and I'm ready to take another crack at it.

The finished game at the end of the tutorial can be seen and played here:

An implementation of the game Tetris

Use the ASD or arrow keys to move the pieces, use "W" or arrow up to rate, and press space to make the piece fall instantaneously.

A simple gameloop

The first thing we want to do is create a gameloop. A gameloop is an infinite loop that performs the logic of the game, and then renders the game.

Where "logic" of a game is: checking for collisions, moving enemies, handling player input, updating the score etc etc.

The "render" is making the game appear on the players screen, in our case this is going to be a HTML canvas element.

Since this blog is written in React it is easiest for me to make the entire game a React component, but I'll try to use React as little as possible, and use TypeScript as much as possible.

Here is our starting point:

import React from 'react';
import { useRef, useEffect } from 'react';

// The dimensions of the tetris game.
const WIDTH = 300;
const HEIGHT = 500;

// The framerate of the game is constant, and does not affect the
// speed at which the game runs, only the speed at which the game
// is rendered.
const FRAME_RATE = 1000 / 20;

export function Tetris() {
  // A reference to the actual <canvas> element.
  const canvasRef = useRef<HTMLCanvasElement | null>(null);

  useEffect(() => {
    if (canvasRef.current) {
      // Get the actual <canvas> element.
      const canvas = canvasRef.current;

      // Try getting a 2d context, this only fails in rare cases,
      // which we will not handle.
      const ctx = canvas.getContext('2d');

      if (ctx) {
        // Create a game loop at the framerate
        const intervalId = window.setInterval(() => {
          tick();

          render(ctx);
        }, FRAME_RATE);

        // Clean up the interval when the Tetris component
        // is dismounted.
        return () => {
          window.clearInterval(intervalId);
        };
      }
    }
  }, []);

  return (
    <div className="flex justify-center">
      <canvas ref={canvasRef} width={WIDTH} height={HEIGHT} className="mb-4">
        An implementation of the game Tetris
      </canvas>
    </div>
  );
}

function tick() {
  // Nothing yet
}

// Render

function render(ctx: CanvasRenderingContext2D): void {
  // Fill the entire canvas with black to test it out
  ctx.fillStyle = 'black';
  ctx.fillRect(0, 0, WIDTH, HEIGHT);

}

The code starts by setting up some constants such as the WIDTH, HEIGHT of the canvas, and the FRAME_RATE of the game.

Then we define a React component called Tetris and we let it render the canvas element.

In the React component we use useRef to get access to the canvas element, then in the useEffect we create our gameloop.

The gameloop is a setInterval call, which runs the provided function code at the given FRAMERATE interval. In this function the tick and render functions are called. This means that the function given to the setInterval is our gameloop.

Our render function draws a black rectangle over the entire canvas, not much to look at but it is a start:

An implementation of the game Tetris

Rendering a topbar

Before we are going to render tetris pieces it is handy to divide the canvas into two: a playing field and a scoring / topbar area.

I want to do this as early on so I don't have to retroactively add the size of the topbar to every calculation. Best to have it there from the beginning.

Lets alter the render and add a topbar:

// The height of the topbar, in which the score and title of the
// game is displayed.
const TOPBAR_HEIGHT = 100;

function render(ctx: CanvasRenderingContext2D): void {
  // Fill the entire canvas with white so we reset the canvas.
  ctx.fillStyle = 'white';
  ctx.fillRect(0, 0, WIDTH, HEIGHT);

  // Set the font
  ctx.font = 'bold 32px mono';

  // Render the title
  const text = 'Tetris';
  ctx.fillStyle = 'red';
  ctx.fillText(text, 10, TOPBAR_HEIGHT / 2 + 10);

  // From now on render all text as black
  ctx.fillStyle = 'black';

  // Render the score
  const score = "1337"; // The score is hardcoded for now!
  // measureText gives back the size in pixels the text will have
  // given the ctx.font, useful for when you want to center the text.
  const size = ctx.measureText(score);
  ctx.fillText(score, WIDTH - size.width - 10, TOPBAR_HEIGHT / 2 + 10);

  // Render the black dividing line between the topbar and playfield
  ctx.fillRect(0, TOPBAR_HEIGHT - 5, WIDTH, 5);
}

The first thing that has changed is rendering a white rectangle across the entire screen on each render. This is very important, as it makes sure we are literally working with a blank canvas each render. Otherwise we would have to manually erase everything we rendered the previous loop.

The one thing that stands out the most is the use of the measureText API. It allows you to ask the canvas element how large (in pixels) the provided text, given the current font is going to be. This allows you to do all sorts of calculations, and in our case puts the score to the right of the playing field.

This is the real power of the canvas element: you have complete control over where things are rendered.

An importat thing to note is that a canvas element renders with a context. This context determines the color, font and stoke of what is drawn. If you set the color to "red" it well draw every rectangle and line with the color "red", until you set it to another color.

This means that you never explicitly draw a "red rectangle", instead you set the color to "red" and then draw a rectangle, which happens to be red because that was the color of the context at that point.

The new render results in the following canvas, showing a topbar a title of the game and a score:

An implementation of the game Tetris

Rendering blocks

Now that we have a topbar we can focus on rendering a playing field and tetris pieces.

A tetris piece is composed out of multiple rectangular blocks of a fixed size. The playing field is also comprised of the same sided blocks in a large grid.

I want to focus rendering these blocks first, so we can compose pieces of them later on:

// The number of rows (vertical) and columns (horizontal).
const NO_COLS = 15;
const NO_ROWS = 20;

// The size in pixels of a single block / cell.
const BLOCK_SIZE = 20;

// Types

type Position = { x: number; y: number };

function createField(): string[][] {
  const colors = ["red", "white", "blue"];

  const field: string[][] = [];

  for (let row = 0; row < NO_ROWS; row++) {
    const rowArray: string[] = [];
    for (let col = 0; col < NO_COLS; col++) {
      // This creates an interesting repeating pattern.
      const color = colors[(col + row) % colors.length]; 

      rowArray.push(color);
    }

    field.push(rowArray);
  }

  return field;
}

// Render

function render(ctx: CanvasRenderingContext2D): void {
  // Fill the entire canvas with white so we reset the canvas.
  ctx.fillStyle = 'white';
  ctx.fillRect(0, 0, WIDTH, HEIGHT);

  const field = createField();

  // Render each position in the field.
  for (let y = 0; y < field.length; y++) {
    for (let x = 0; x < field[y].length; x++) {
      const color = field[y][x] ?? 'white';

      renderBlock({ x, y }, color, ctx);
    }
  }

  // Set the font
  // Truncated for brevity but same code as before
}

function renderBlock(
  { x, y }: Position,
  color: string,
  ctx: CanvasRenderingContext2D
) {
  ctx.fillStyle = color;

  // Render color
  ctx.fillRect(
    x * BLOCK_SIZE,
    TOPBAR_HEIGHT + y * BLOCK_SIZE,
    BLOCK_SIZE,
    BLOCK_SIZE
  );
}

The number of columns and rows and the size of the blocks where chosen by fiddling with them until they looked good.

The NO_ROWS is a perfectly divided by the WIDTH, and the NO_COLS is perfectly divided by the HEIGHT - TOPBAR_HEIGHT. To make the grid appear uniform.

The renderBlock is a separate function so the pieces can be rendered using the same logic later.

I'd also like to point out that a canvas starts at the top-left. Meaning 0,0 is at the most top left of the canvas. The higher the value of the y axis the more you go to the bottom!

Now take a look at all the blocks in the playing field:

An implementation of the game Tetris

Rendering all tetris pieces

Now that we can render a playing field, we can take a look at all the tetris pieces that exist:

Shows all tetris pieces and where the center point is.

Each piece or tetromino as they are called, has a name that maps to a letter, a color, and a center point around which they rotate.

We will put each piece on the screen randomly at an interval:

// The horizontal center, used to spawn pieces in the middle of the field
const CENTER_X = Math.floor(NO_COLS / 2);

// Dropped the framerate
const FRAME_RATE = 1000;

// Types

type PieceType = 'I' | 'J' | 'L' | 'O' | 'S' | 'T' | 'Z';
type Color = string;
type Position = { x: number; y: number };

type Piece = {
  /**
   * The type of the piece.
   */
  type: PieceType;

  /**
   * The color for the piece.
   */
  color: Color;

  /**
   * The current position the piece takes up in the field.
   */
  positions: Position[];

  /**
   * Which index of the positions is considered the center, this
   * information is needed to determine how to rotate the piece.
   */
  center: number;
};

// Render

function render(ctx: CanvasRenderingContext2D): void {
  // Fill the entire canvas with white so we reset the canvas.
  ctx.fillStyle = 'white';
  ctx.fillRect(0, 0, WIDTH, HEIGHT);

  // Draw a random piece
  renderPiece(randomPiece(), ctx);

  // Set the font
  // Truncated for brevity but same code as before
}

function renderPiece(piece: Piece, ctx: CanvasRenderingContext2D) {
  for (const position of piece.positions) {
    renderBlock(position, piece.color, ctx);
  }
}

// Piece creators

function createI(): Piece {
  const piece: Piece = {
    type: 'I',
    color: 'cyan',
    positions: [
      /*
          0
          1
          2
          3
      */
      { x: CENTER_X, y: 0 },
      { x: CENTER_X, y: 1 },
      { x: CENTER_X, y: 2 },
      { x: CENTER_X, y: 3 },
    ],

    center: 1,
  };

  return piece;
}

function createJ(): Piece {
  return {
    type: 'J',
    color: 'deepskyblue',
    positions: [
      /*
          0
          123
      */
      { x: CENTER_X - 1, y: 0 },
      { x: CENTER_X - 1, y: 1 },
      { x: CENTER_X, y: 1 },
      { x: CENTER_X + 1, y: 1 },
    ],
    center: 2,
  };
}

function createL(): Piece {
  return {
    type: 'L',
    color: 'orange',
    positions: [
      /*
            0
          321
      */
      { x: CENTER_X + 1, y: 0 },
      { x: CENTER_X + 1, y: 1 },
      { x: CENTER_X, y: 1 },
      { x: CENTER_X - 1, y: 1 },
    ],
    center: 2,
  };
}

function createO(): Piece {
  return {
    type: 'O',
    color: 'gold',
    positions: [
      { x: CENTER_X, y: 0 },
      { x: CENTER_X + 1, y: 0 },
      { x: CENTER_X + 1, y: 1 },
      { x: CENTER_X, y: 1 },
    ],
    center: -1,
  };
}

function createS(): Piece {
  return {
    type: 'S',
    color: 'green',
    positions: [
      /*
          02
         31
      */
      { x: CENTER_X, y: 0 },
      { x: CENTER_X, y: 1 },
      { x: CENTER_X + 1, y: 0 },
      { x: CENTER_X - 1, y: 1 },
    ],
    center: 1,
  };
}

function createT(): Piece {
  return {
    type: 'T',
    color: 'purple',
    positions: [
      /*
         3
        012
      */
      { x: CENTER_X - 1, y: 1 },
      { x: CENTER_X, y: 1 },
      { x: CENTER_X + 1, y: 1 },
      { x: CENTER_X, y: 0 },
    ],
    center: 1,
  };
}

function createZ(): Piece {
  return {
    type: 'Z',
    color: 'red',
    positions: [
      /*
         20
          13
      */
      { x: CENTER_X, y: 0 },
      { x: CENTER_X, y: 1 },
      { x: CENTER_X - 1, y: 0 },
      { x: CENTER_X + 1, y: 1 },
    ],
    center: 1,
  };
}

const PIECES = [createI, createJ, createL, createO, createS, createT, createZ];

function randomPiece(): Piece {
  const random = Math.floor(Math.random() * PIECES.length);

  return PIECES[random]();
}

As you can see each piece has a corresponding creator function, that creates an instance of the piece, at the top-center of the playing field.

Determining the positions was done by manual labor. My process was writing down in a "comment" what the shape of the piece looks like. Each number representing the index within the positions array.

Then by rendering out all pieces I check them to see if I made any errors:

An implementation of the game Tetris

Rotating tetris pieces

Now for the most dreaded part... rotation. The part that knocked me out al those years ago.

This time around I had a better sense what to do, but tried two different approaches.

A. manually describing the rotations

The first approach was looking at each piece and manually writing out the transformations for each rotation.

Each piece was given a rotations array, which contained four sub arrays. The sub arrays had items for each position of the piece. Each item was an tuple of two numbers: the first number what needed to happen to the x and the second number what needed to happen to the y.

So for example [-1, 1] meant decrease x by one, increase y by one, for that position.

This way for each position of the piece I knew for each rotation what needed to happen.

This actually worked and I wrote these rotations for two pieces, but it felt a little like to much work, and to much code, plus it also was quite error prone.

So I went with plan B.

B. mathematically describing the rotations

My math skills are a little rusty, but I knew a rotation formula had to exists. After some searching I landed on the wikipedia page for Rotation Matrix, it explains how to do 2D rotations under the heading "Common 2d rotations".

This resulted in the following code:

function rotatePiece(piece: Piece): void {
  // The O / block piece cannot be rotated.
  if (piece.type === 'O') {
    return;
  }

  // Get a reference to the center block of the tetris piece.
  const center = piece.positions[piece.center];

  piece.positions = piece.positions.map(({ x, y }) => {
    // First calculate the distance between the center block
    // and the block.
    const dx = x - center.x;
    const dy = y - center.y;

    // Now rotate 90 degrees but take into account the center piece.
    const newX = 0 * dx - 1 * dy + center.x;
    const newY = 1 * dx + 0 * dy + center.y;

    // See: https://en.wikipedia.org/wiki/Rotation_matrix heading "Common 2d rotations"
    return { x: newX, y: newY };
  });
}

// Render
let pieces = PIECES.map((f) => f());

function render(ctx: CanvasRenderingContext2D): void {
  // Fill the entire canvas with white so we reset the canvas.
  ctx.fillStyle = 'white';
  ctx.fillRect(0, 0, WIDTH, HEIGHT);

  pieces.forEach((piece) => {
    rotatePiece(piece);
    renderPiece(piece, ctx);
  });

  // Set the font
  // Truncated for brevity but same code as before
}

Taking the formula on wikipedia and turning it into JavaScript / TypeScript was not easy for me, given my rusty math. So I had to use my whiteboard a lot to visualize this.

The most difficult part was taking into account that when you rotate something around a center, you rotate relative to that center, the further away a position / block is from the center is the more you rotate. This is represented by the dx and dy variables, the d stands for "distance".

I had to consult a lot of resources to come up with the rotation function, and I cannot take full credit for it.

Here is the rotation in action:

An implementation of the game Tetris

I really got stuck on this problem, and found the feedback cycle to be to slow, by feedback cycle I mean that it took to long for me to see the results of the rotation. Since had to wait for a specific faulty rotation to be displayed.

Debugging then became a nightmare. I find that whenever a problem is to difficult to solve due to the environment the code is written in, and by environment I mean the code surrounding the difficult to debug code, it is best to extract / isolate the problem!

I did so by creating a sort of unit test file for rotations:

const positions = [
  { x: 7, y: 0 },
  { x: 7, y: 2 },
  { x: 7, y: 3 },
];

const center = { x: 7, y: 1 };

const expected = [
  { x: 8, y: 1 },
  { x: 6, y: 1 },
  { x: 5, y: 1 },
];

function rotate(positions) {
  // 90 Degrees clockwise rotation
  return positions.map((p) => {
    const newX = 0 * ( p.x - center.x ) - 1 * ( p.y - center.y ) + center.x;
    const newY = 1 * ( p.x - center.x ) + 0 * ( p.y - center.y ) + center.y;

    return { x: newX, y: newY };
  });
}

const results = rotate(positions);

for (let i = 0; i < expected.length; i++) {
  const result = results[i];
  const expect = expected[i];

  const failed = result.x !== expect.x || result.y !== expect.y;

  const msg = failed ? 'Failed' : 'Passed';

  console.log(i, msg, 'r', result, 'e', expect);
}

This allowed me to plugin specific pieces at specific rotations in isolation to see what went wrong, without any delay. Thus creating a faster feedback cycle.

Modeling tetris and adding a tick

Now that the hard part is over lets model a game of tetris:

// The framerate of the game is constant, and does not affect the
// speed at which the game runs, only the speed at which the game
// is rendered.
const FRAME_RATE = 1000 / 20;

export function Tetris() {
  // Truncated for brevity but same code as before

  useEffect(() => {
    if (canvasRef.current) {
      // Truncated for brevity but same code as before

      if (ctx) {
        const tetris = createTetris();

        // Create a game loop at the framerate
        const intervalId = window.setInterval(() => {
          tick(tetris);

          render(tetris, ctx);
        }, FRAME_RATE);

        return () => {
          window.clearInterval(intervalId);
        };
      }
    }
  }, []);

  // Truncated for brevity but same code as before
  return ();
}

// Types

type Row = Color | null;
type Field = Row[][];

type Tetris = {
  /**
   * The piece that the player can currently move.
   */
  piece: Piece;

  /**
   * The playing field, a grid of colors, when the cell is null it
   * means that no piece ever landed there and that it is empty.
   * When the cell has a color it means that a piece landed there.
   */
  field: Field;

  /**
   * The fallrate the piece currently has, as the player scores more
   * points the fallRate is decreased, meaning it will fall faster.
   */
  fallRate: number;

  /**
   * The time of the last tick, used to calculate whether or not
   * to perform the tick.
   */
  lastTick: number;
};

function tick(tetris: Tetris) {
  // The pieces should fall at a certain rate, so we take the current
  // time and check if it is before the delta + fallrate. If so
  // this tick needs to be ignored.
  const time = new Date().getTime();
  if (time < tetris.lastTick + tetris.fallRate) {
    return;
  }
  tetris.lastTick = time;

  // Perform rest of logic here, but rotate for now:
  rotatePiece(tetris.piece);
}

function createTetris(): Tetris {
  const field: Field = [];

  for (let row = 0; row < NO_ROWS; row++) {
    const row: Row[] = [];
    for (let col = 0; col < NO_COLS; col++) {
      row.push(null);
    }
    field.push(row);
  }
  

  const piece = randomPiece();

  return {
    piece,
    field,
    fallRate: 800,
    lastTick: new Date().getTime(),
  };
}

// Render

function render(tetris: Tetris, ctx: CanvasRenderingContext2D): void {
  // Fill the entire canvas with white so we reset the canvas.
  ctx.fillStyle = 'white';
  ctx.fillRect(0, 0, WIDTH, HEIGHT);

  // Render each position in the field.
  for (let y = 0; y < tetris.field.length; y++) {
    for (let x = 0; x < tetris.field[y].length; x++) {
      const color = tetris.field[y][x] ?? 'white';

      renderBlock({ x, y }, color, ctx);
    }
  }

  renderPiece(tetris.piece, ctx);

  // Set the font
  // Truncated for brevity but same code as before
}

The new code adds a Tetris type, the idea is that the entire state for the game is stored here. An instance of Tetrisis created by the createTetris function, and called in the useEffect before starting the gameloop.

The render function now accepts a Tetris and renders it, and will no longer render a random piece.

The tick function has also been updated, it also accepts a Tetris and rotates the piece. It does however not rotate on every tick it only rotates after the fallRate has passed.

The trick here is to store the last time the tick occurred, and compare it to the current time of the tick. If it the current time exceeds the last time plus the fall rate it should perform the rest of the logic.

Now we have a self rotating piece which rotates at the fallRate:

An implementation of the game Tetris

Falling down

Lets actually add the fall logic to the game:

function tick(tetris: Tetris) {
  // The pieces should fall at a certain rate, so we take the current
  // time and check if it is before the delta + fallrate. If so
  // this tick needs to be ignored.
  const time = new Date().getTime();
  if (time < tetris.lastTick + tetris.fallRate) {
    return;
  }
  tetris.lastTick = time;

  if (piecePlayed(tetris.piece, tetris.field)) {
    // When the piece is played color the field the same color
    // as the piece at the pieces final position.
    for (const { x, y } of tetris.piece.positions) {
      tetris.field[y][x] = tetris.piece.color;
    }

    // and generate a new piece.
    tetris.piece = randomPiece();
  } else {
    dropPiece(tetris.piece);
  }
}

function piecePlayed(piece: Piece, field: Field): boolean {
  // The piece is played when it will collide with the bottom
  // row or another piece
  return piece.positions.some(({ x, y }) => hasCollision(field, x, y + 1));
}

function hasCollision(field: Field, x: number, y: number): boolean {
  // Check if there is a collision with the right and left walls.
  if (x < 0 || x >= NO_COLS) {
    return true;
  }

  // Check if there is a collision with the bottom and top walls
  if (y < 0 || y >= NO_ROWS) {
    return true;
  }

  // Finally check if the field at the provided position is not
  // filled in with a color. If there is a color present this means
  // this is another piece's final resting place.
  return field[y][x] !== null;
}

function dropPiece(piece: Piece) {
  // To drop a piece increase the y by one, remember that
  // in the Canvas API the top of the canvas has a y of zero!
  // So increasing the y moves the piece down.
  piece.positions = piece.positions.map(({ x, y }) => {
    return { x, y: y + 1 };
  });
}

The thing here is to understand that whenever a piece is played, it no longer is kept around in memory. Meaning the Piece instance is not retained. What happens instead is that the field of the Tetris is given a color at the final position of the piece.

This means that the piece is visually there, but in memory it is gone! In effect this means that only one piece is in memory at a time.

This also explains why in the hasCollision function we do not have to loop over all previous pieces, since they are not there. Instead we check if the field has a color on that position.

The result is a tetris game that accepts no player movement, and does not know when the game is over:

An implementation of the game Tetris

By the time you see the above example, it is most likely at the game over stage, refresh the page to see the pieces drop.

Player Movement

Lets add player movement next:

export function Tetris() {
  // A reference to the actual <canvas> element.
  const canvasRef = useRef<HTMLCanvasElement | null>(null);

  // A reference to the tetris game, needed so we can listen to the
  // keyboard events and set them inside the tetris.keyboard key property.
  const tetrisRef = useRef<Tetris | null>(null);

  useEffect(() => {
    if (canvasRef.current) {
      // Same as before abbreviated for brevity.

      if (ctx) {
        // Create a tetris object which represents the game.
        const tetris = createTetris();

        // Store it inside of a React ref so we can access it when
        // listing to key events.
        tetrisRef.current = tetris;

        // Same as before abbreviated for brevity.
      }
    }
  }, []);

  // Listen to the key events
  useEffect(() => {
    function keydown(event: KeyboardEvent) {
      const tetris = tetrisRef.current;

      if (!tetris) {
        return;
      }

      // Listen to both arrow and wasd keys
      if (event.code === 'ArrowLeft' || event.code === 'a') {
        tetris.keyboardKey = 'left';
      } else if (event.code === 'ArrowRight' || event.code === 'd') {
        tetris.keyboardKey = 'right';
      } else if (event.code === 'ArrowDown' || event.code === 's') {
        tetris.keyboardKey = 'down';
      } else if (event.code === 'Space') {
        tetris.keyboardKey = 'space';
      } else if (event.code === 'ArrowUp' || event.code === 'w') {
        tetris.keyboardKey = 'rotate';
      } else {
        tetris.keyboardKey = null;
      }

      if (tetris.keyboardKey) {
        event.preventDefault();
      }
    }

    document.addEventListener('keydown', keydown);

    // Unsubscribe whenever the player leaves the page.
    return () => {
      document.removeEventListener('keydown', keydown);
    };
  });

  // Same as before abbreviated for brevity.
  return ();
}

// Types

type KeyboardKey = 'left' | 'right' | 'down' | 'space' | 'rotate' | null;

type Tetris = {
  // Same as before keyboardKey was added.

  /**
   * Which event the player wants to perform the next tick.
   */
  keyboardKey: KeyboardKey | null;
};

function tick(tetris: Tetris) {
  // Same as before abbreviated for brevity.

  if (piecePlayed(tetris.piece, tetris.field)) {
    // Same as before abbreviated for brevity.
  } else {
    if (tetris.keyboardKey === 'rotate') {
      rotatePiece(tetris.piece);
    }

    // Move the piece based on keyboard input, otherwise when
    // no player input is found drop it by one.
    movePiece(tetris);
  }

   // We have handled the event
   tetris.keyboardKey = null;
}

function movePiece(tetris: Tetris): void {
  const { piece, field } = tetris;

  // First calculate the positions based on the user input.
  const newPositions = piece.positions.map(({ x, y }) => {
    // Always move down at least one position.
    let newY = y + 1;

    let newX = x;
    if (tetris.keyboardKey === 'left') {
      newX = x - 1;
    } else if (tetris.keyboardKey === 'right') {
      newX = x + 1;
    } else if (tetris.keyboardKey === 'down') {
      newY = y + 2;
    }
    return { x: newX, y: newY };
  });

  // If the positions have collided, which can happen if the player
  // tried moving the piece into another piece, we simply ignore
  // the player input and shift the piece down by one position.
  if (newPositions.some(({ x, y }) => hasCollision(field, x, y))) {
    dropPiece(piece);
  } else {
    piece.positions = newPositions;
  }
}

function createTetris(): Tetris {
  // Same as before abbreviated for brevity.

  return {
    // Same as only keyboardKey was added.

    keyboardKey: null
  };
}

There is a lot going on in this change: first we add a keyboardKey to the Tetris type so we know which key is pressed. It is of type KeyboardKey which represents all actions (and future actions) the player can take.

Second in the React code we need to listen to keyboard events so we create another useEffect that listens to all keyboard events, and unsubscribes whenever the component is cleaned up.

We only listen to one event at a time, this is done by making the tick do tetris.keyboardKey = null; which clears the player input. The effect is that the player has to bash the keys to make the piece move instead of holding the button, which I think is more fun.

The movePiece function is what actually handles the input. Then main thing here is that we first calculate the position to which the player wants to move the piece, and then we check if that movement did not collide with something.

If it does collide we simply ignore it and call dropPiece instead. This prevents the game from entering an illegal state: a state in which blocks merge together.

Another behavior is that a rotation is always followed by a drop. Otherwise you could keep rotating the piece and stay mid air forever, which is against the spirit of the game.

Try moving the piece yourself:

An implementation of the game Tetris

Game over

Now lets tackle the game over scenario, the game is over when a piece is colliding when spawned.

type Tetris = {
  // Same as before isGameOver was added.

  /**
   * Whether or not the game is over.
   */
  isGameOver: boolean;
};

function tick(tetris: Tetris) {
  // Same as before abbreviated for brevity.

  // When the game is over we need not do anything, except check if
  // the player wants to restart the game.
  if (tetris.isGameOver) {
    // Restart if space is pressed.
    if (tetris.keyboardKey === 'space') {
      Object.assign(tetris, createTetris());
      tetris.keyboardKey = null;
    }

    return;
  }

  if (piecePlayed(tetris.piece, tetris.field)) {
    // Same as before abbreviated for brevity.

    // If that new piece is played on spawn the game is over.
    tetris.isGameOver = piecePlayed(tetris.piece, tetris.field);
  } else {
    // Same as before abbreviated for brevity.
  }

  // We have handled the event
  tetris.keyboardKey = null;
}

function createTetris(): Tetris {
  // Same as before abbreviated for brevity.

  return {
    // Same as only isGameOver was added.
    
    isGameOver: false,
  };
}

// Render

function render(tetris: Tetris, ctx: CanvasRenderingContext2D): void {
  // Same as before abbreviated for brevity.

  // Render the title
  const text = tetris.isGameOver ? 'Game Over' : 'Tetris';
  ctx.fillStyle = 'red';
  ctx.fillText(text, 10, TOPBAR_HEIGHT / 2 + 10);

  // Same as before abbreviated for brevity.

  // Render the black dividing line between the topbar and playfield
  ctx.fillRect(0, TOPBAR_HEIGHT - 5, WIDTH, 5);

  // Render an instruction on how to restart the game when it is over
  if (tetris.isGameOver) {
    // First render a white transparent border
    const borderHeight = 100;
    ctx.globalAlpha = 0.6;
    ctx.fillStyle = 'white';
    ctx.fillRect(0, HEIGHT / 2 - borderHeight / 2, WIDTH, borderHeight);
    ctx.globalAlpha = 1;

    ctx.fillStyle = 'black';

    // Then render the restart text
    const restartText = 'Press space to restart';
    const size = ctx.measureText(restartText);
    ctx.fillText(restartText, WIDTH - size.width - 10, HEIGHT / 2 + 10);
  }
}

What we did was add a isGameOver boolean to the Tetris and whenever the game is over we render "Game Over" as the title and show instructions on how to restart the game.

In the tick we do nothing when the game is over except allow the player to restart the game.

Here we use a trick: we call Object.assign which mutates an object by merging it with another object. We provide it the tetris to mutate, and a clean Tetris by calling createTetris(). This effectively reset the tetris object.

The reason I mutate the tetris object directly is so I do not have to go back to the React code and update the useRef. I consider it a little nasty to do it via a mutation, but it saves me a lot of time.

Take a look a the "Game Over" screen:

An implementation of the game Tetris

Scoring

In tetris you score whenever a row is completely filled in by blocks:

type Tetris = {
  // Same as only score and rowsScored were added.

  /**
   * The score of the current game.
   */
  score: number;

  /**
   * The number of rows scored in the current game, used to calculate
   * a bonus.
   */
  rowsScored: number;
};

function tick(tetris: Tetris) {
  // Same as before abbreviated for brevity.

  if (piecePlayed(tetris.piece, tetris.field)) {
    // When the piece is played color the field the same color
    // as the piece at the pieces final position.
    for (const { x, y } of tetris.piece.positions) {
      tetris.field[y][x] = tetris.piece.color;
    }

    // Remove the finished rows, and get back how many rows have been finished.
    const finishedRows = removeFinishedRows(tetris);

    if (finishedRows > 0) {
      updateScore(tetris, finishedRows);

      tetris.rowsScored += finishedRows;

      // The fallRate decreases / speeds up for every finished row.
      tetris.fallRate -= finishedRows * 25;
    }

    // Generate a new piece
    tetris.piece = randomPiece();

    // If that new piece is played on spawn the game is over.
    tetris.isGameOver = piecePlayed(tetris.piece, tetris.field);
  } else {
    // Same as before abbreviated for brevity.
  }

  // We have handled the event
  tetris.keyboardKey = null;
}

function removeFinishedRows(tetris: Tetris): number {
  // Keep all rows which are not finished
  const newField = tetris.field.filter((row) => {
    // A row is finished if every cell has a color
    return row.some((cell) => cell === null);
  });

  // The number of finished rows is the total number of rows 
  // minus the rows that where not finished.
  const noFinishedRows = NO_ROWS - newField.length;

  for (let i = 0; i < noFinishedRows; i++) {
    // Add empty rows at the start of the newField array.
    // This will push all remaining rows down!
    newField.unshift(emptyRow());
  }

  // finally set update the field.
  tetris.field = newField;

  // and return the number of finished rows for scoring.
  return noFinishedRows;
}

function updateScore(tetris: Tetris, finishedRows: number) {
  // Calculate a bonus based on the rows scored at this point.
  const bonus = tetris.rowsScored * 10;

  // Reward a 100 points per finishedRow and a multiplier for each row
  // finished by this piece. This way the player is rewarded for removing
  // multiple lines with one piece.
  const score = finishedRows * 100 * finishedRows;

  // Update the score
  tetris.score += score + bonus;
}

function createTetris(): Tetris {
  const field: Field = [];

  for (let row = 0; row < NO_ROWS; row++) {
    field.push(emptyRow());
  }

  const piece = randomPiece();

  return {
    // Same as only score and rowsScored were added.
    score: 0,
    rowsScored: 0,
  };
}

function emptyRow() {
  const row: Row[] = [];
  for (let col = 0; col < NO_COLS; col++) {
    row.push(null);
  }
  return row;
}

// Render

function render(tetris: Tetris, ctx: CanvasRenderingContext2D): void {
  // Same as before abbreviated for brevity.

  // Render the score
  const score = tetris.score.toString();
  
  // Same as before abbreviated for brevity.
}

The updateScore is where the score is determined: the more rows the player clears at once the more he is rewarded. This adds an element of risk / reward to the game.

Another staple of tetris is that the pieces fall faster and faster as the player clears rows, eventually making it harder and harder to keep playing.

We now have a playable tetris game:

An implementation of the game Tetris

Adding a ghost

In tetris game that I once played you saw a ghost of the current piece, the ghost is seen at the position where the piece would drop if the player gave no input.

This allows the player to more easily play the game, so lets add a ghost:

type Tetris = {
  // Same as before ghost was added.

  /**
   * A ghost (gray) represents the position to where the piece will fall
   * if the player does not do anything / does not move or rotate
   * the piece.
   *
   * The ghost allows the player to more easily see what the piece
   * will do.
   */
  ghost: Piece;
};

function tick(tetris: Tetris) {
  // Truncated for brevity but same code as before

  // Determine where the ghost is at this point, by doing this each
  // tick the ghost is always accurate.
  tetris.ghost = ghostForPiece(tetris.piece, tetris.field);

  // We have handled the event
  tetris.keyboardKey = null;
}

function ghostForPiece(piece: Piece, field: Field): Piece {
  // Step 1: clone the piece object, so the ghost does not interfere
  // with the real piece.
  const ghost = structuredClone(piece);

  // Step 2: continue dropping the ghost down until it has hit
  // either the bottom or another piece
  while (!piecePlayed(ghost, field)) {
    dropPiece(ghost);
  }

  // At this point the ghosts position is the position the piece will
  // have if the player does move the piece.

  // Step 3: make the ghost gray so the player knows it is the ghost.
  ghost.color = 'gray';

  return ghost;
}

function createTetris(): Tetris {
  // Truncated for brevity but same code as before

  return {
    // Same as before ghost was added.
    ghost: ghostForPiece(piece, field),
  };
}

// Render

function render(tetris: Tetris, ctx: CanvasRenderingContext2D): void {
  // Truncated for brevity but same code as before

  // Render the ghost first and then the piece, so when there is
  // overlap between the piece and the ghost, the piece renders
  // on top of the ghost.
  renderPiece(tetris.piece, ctx);
  renderPiece(tetris.ghost, ctx);

  // Truncated for brevity but same code as before
}

The main addition is the ghostForPiece function, which uses a while statement to calculate where the piece would fall without input.

A piece of advice for novice programmers: while loops are useful for when you do not know beforehand how many iterations are needed.

This is why in my day to day work as a web developer I don't use the while all that much. Most of the time I iterate over a list / array of things, and then you know exactly how many items there are. In that case I would advise you use forEach or for of statements instead.

Take a look at our not so scary ghost:

An implementation of the game Tetris

Instadrop

In the beginning of a game of tetris the pieces fall at a glacial pace, and I find this kind of painful to watch. So for our final touch we are going to add an "instadrop" mechanic, to speed up the game.

Whenever the user presses the "space" we are going to instantly drop the piece to its final resting position, which luckily for us is the same as the ghosts position:

function tick(tetris: Tetris): void {
  // Truncated for brevity but same code as before

  // Has the piece finished dropping down the playfield
  if (piecePlayed(tetris.piece, tetris.field)) {
    // Truncated for brevity but same code as before
  } else {
    // When space is pressed drop the piece at the ghosts position,
    // but do not drop the piece any further, or it will fall through
    // the bottom.
    if (tetris.keyboardKey === 'space') {
      tetris.piece.positions = tetris.ghost.positions;
    } else {
      if (tetris.keyboardKey === 'rotate') {
        rotatePiece(tetris.piece);
      }

      // Move the piece based on keyboard input, otherwise when
      // no player input is found drop it by one.
      movePiece(tetris);
    }
  }

  // Truncated for brevity but same code as before
}

The implementation was made easy by already having done the ghost first. The one tricky bit is not calling movePiece when the player uses "instadrop" otherwise the piece will fall through the bottom.

Now for the final game:

An implementation of the game Tetris

Finished

The game is now finished, and clocks in around 660 lines of code, not bad if I say so myself.

My 6 year old son saw me playing / creating this game and he tried playing it as well, and he enjoyed it!

I'll take that as a win!

Here a link to the full code for reference.

Please share this post if you enjoyed it.