Multiplayer Tetris Game (part 1)

A tutorial about writing a multiplayer game from scratch. Starting from solidifying the requirements and continues all the way up to creating a working prototype with vanilla js. By the end of the tutorial we will have the following game.

Press `W, A, S, D` keys or the arrow keys to play

Before We Start

At a high level, these are our requirements for the game we're building with this tutorial. This is something similar to what we would get as requirements from an end user.

Requirements for Version 1

  • R1: it should be possible for multiple players to play the game
  • R2: it should support all the basic rules of a "Tetris like" game

Let's break these down further and decide how we're going to solve these. I'm pretty sure we probably have this on our minds but it helps to write these things down.

"R1: it should be possible for multiple players to play the game"

There are many ways to do this. We'll go old school and build a "split-screen" multiplayer game. This way we don't need to build any server side components.

"R2: it should support all the basic rules of a "Tetris like" game"

What are rules of a tetris like game? let's work on this while we are building the game. We can add a few to get started.

  • R2.1: Shapes should never overlap with other shapes in the game.
  • R2.2: There is an "active shape" per player which can be rotated or moved left, right and down by the player.
  • R2.3: The active shape moves 1 unit downwards every 0.5 seconds until it lands on the floor or on another shape.
  • R2.4: A new shape is added when the game starts and when the active shape can no longer move downwards.
  • R2.5: When a new shape is added, it becomes the active shape and the previous active shape becomes inactive.
  • R2.6: The game is over when the shapes reaches the game world ceiling.

Basic Components

Before building, let's list down all the basic components. If we get this good enough, it may survive this version and some future versions of this game. Making changes here will usually take more time than changing other parts of the code.

Basic components of the game

Shapes and Shape Types

In the game world, a shape would have a position and a type. It also needs a color so different players can have different colors. And we can change the color to represent other states as well (inactive?).

interface Shape {
  x: number;
  y: number;
  type: ShapeType;
  color: string;
}

We can represent each shape type as a 2D array. The '1's represent filled blocks and the '0's will represent holes.

type ShapeType = number[][];

And some example shape types we can use in the game. We have the square, "L", "T" and the "I" in order.

const SHAPE_TYPES: ShapeType[] = [
  [
    [1, 1],
    [1, 1],
  ],
  [
    [1, 0, 0],
    [1, 1, 1],
  ],
  [
    [0, 1, 0],
    [1, 1, 1],
  ],
  [[1, 1, 1]],
];

Actions from the Player

Let's define actions as simple functions which modify the active shape. For example, the moveLeft and moveRight functions will modify the 'x' position of the shape.

type PlayerAction = (shape: Shape) => void;

And some example player actions we can use in the game.

const ACTION_TYPES: { [name: string]: PlayerAction } = {
  moveLeft: (shape) => {
    shape.x--;
  },

  moveRight: (shape) => {
    shape.x++;
  },

  moveDown: (shape) => {
    shape.y++;
  },

  rotateShape: (shape) => {
    const type = [];
    for (let i = 0; i < shape.type[0].length; ++i) {
      type[i] = [];
      for (let j = 0; j < shape.type.length; ++j) {
        type[i][j] = shape.type[shape.type.length - 1 - j][i];
      }
    }
    shape.type = type;
  },
};

These actions are triggered by user interactions. We can map these actions to keys on the keyboard. Let's do it for 2 players.

type PlayerKeymap = { [key: string]: PlayerAction };

const PLAYER_KEYMAPS: PlayerKeymap[] = [
  {
    KeyW: ACTION_TYPES.rotateShape,
    KeyA: ACTION_TYPES.moveLeft,
    KeyS: ACTION_TYPES.moveDown,
    KeyD: ACTION_TYPES.moveRight,
  },
  {
    ArrowUp: ACTION_TYPES.rotateShape,
    ArrowLeft: ACTION_TYPES.moveLeft,
    ArrowDown: ACTION_TYPES.moveDown,
    ArrowRight: ACTION_TYPES.moveRight,
  },
];

Inside the Game World

There are several sub components in the game world, let's define them one by one. And also explain why we need them.

The Game Clock

The game world takes input from player actions and also from the game clock. When the clock "ticks" we move the active shape down. This is one of the requirements of the game (R2.3). The clock only needs to execute a given function for each tick.

type GameClock = (fn: () => void) => void;

The Game Rules

Next we need some way to filter player actions. Given a player action the rule should decide whether we should apply it or not.

// TODO: would probably need more information to decide the outcome
type GameRule = (action: PlayerAction) => 'apply' | 'dont_apply';

The Game Renderer

The render receives an array of shapes to render in the game. We can group them by player so the renderer receives more information.

type GameRenderer = (shapesets: Shape[][]) => void;

Let's write the code

We will use the types we defined before but we will make changes if we need to.

Listening to Player Actions

The player receives a listener function and calls it when the user performs actions. It will need a listener function and a map of keycode => action functions. Because this adds an event listener, let's return a function which can be used to clean things up.

// the returned function must be called to destroy the player
const startPlayer = (keymap, listener) => {
  const keyDownListener = (e) => {
    for (const key of Object.keys(keymap)) {
      if (e.code === key) {
        listener(keymap[key]);
        break;
      }
    }
  };
  document.addEventListener('keydown', keyDownListener);

  return () => {
    document.removeEventListener('keydown', keyDownListener);
  };
};

And next, the game world. It will bring all the players and the shapes together and renders the output using the given renderer. Let's implement only the part where it listens to player actions and applies them on their active shapes and then renders the result.

We also added an options object which we can use to configure the game world. Right now, it only has the world's width.

const startWorld = (keymaps, renderer, options) => {
  const PLAYER_COLORS = ['#6fa8dc', '#f6b26b'];
  const playersCount = keymaps.length;
  const playerShapes = [];
  const stopPlayerFns = [];
  const requestReRender = () => {
    renderer(playerShapes);
  };
  const createNewShape = (i) => {
    const index = Math.floor(Math.random() * SHAPE_TYPES.length);
    const shape = { x: 0, y: 0, type: SHAPE_TYPES[index], color: '#111' };
    shape.x = Math.floor((options.worldWidth / playersCount / 2) * (i * 2 + 1));
    shape.color = PLAYER_COLORS[i % PLAYER_COLORS];
    playerShapes[i].unshift(shape);
    requestReRender();
  };
  const canPerformAction = (i, action) => {
    // TODO: implement checks
    return true;
  };
  const playerActionListener = (i, action) => {
    if (!canPerformAction(i, action)) {
      return;
    }
    const shape = playerShapes[i][0];
    action(shape);
    requestReRender();
  };
  for (let i = 0; i < playersCount; ++i) {
    playerShapes[i] = [];
    createNewShape(i);
  }
  for (let i = 0; i < playersCount; ++i) {
    const keymap = keymaps[i];
    const listener = (action) => playerActionListener(i, action);
    stopPlayerFns[i] = startPlayer(keymap, listener);
  }

  return () => {
    stopPlayerFns.forEach((fn) => fn());
  };
};

Let's see how it works with a test renderer.

const renderer = (shapes) => {
  console.log('render:');
  console.log(JSON.stringify(shapes[0]));
  console.log(JSON.stringify(shapes[1]));
};
const options = {
  worldWidth: 32,
};
startWorld(keymaps, renderer);

Seems to be working fine :)

Adding a Canvas Renderer

Let's go ahead and create a simple canvas renderer so we can see what's going on. We also added worldHeight and worldScale to the options object.

const createRenderer = (canvas, options) => {
  const canvasWidth = options.worldWidth * options.worldScale;
  const canvasHeight = options.worldHeight * options.worldScale;
  canvas.width = canvasWidth;
  canvas.height = canvasHeight;
  const ctx = canvas.getContext('2d');

  const renderBlock = (x, y, color) => {
    ctx.fillStyle = color;
    ctx.strokeStyle = '#000';
    ctx.fillRect(
      x * options.worldScale,
      y * options.worldScale,
      options.worldScale,
      options.worldScale
    );
    ctx.strokeRect(
      x * options.worldScale,
      y * options.worldScale,
      options.worldScale,
      options.worldScale
    );
  };

  const renderShape = (shape) => {
    ctx.save();
    ctx.translate(shape.x * options.worldScale, shape.y * options.worldScale);
    for (let i = 0; i < shape.type.length; ++i) {
      const row = shape.type[i];
      for (let j = 0; j < row.length; ++j) {
        if (row[j]) {
          renderBlock(j, i, shape.color);
        }
      }
    }
    ctx.restore();
  };

  return (playerShapes) => {
    ctx.clearRect(0, 0, canvasWidth, canvasHeight);
    for (let i = 0; i < playerShapes.length; ++i) {
      const shapes = playerShapes[i];
      for (let j = 0; j < shapes.length; ++j) {
        renderShape(shapes[j]);
      }
    }
  };
};

Adding Gravity and Other Rules

The next step is to make things move downwards. Let's apply these changes to the startWorld function.

const startWorld = (keymaps, renderer, ticker, options) => {
  // ...
  const stopGameClock = ticker(() => {
    for (let i = 0; i < playerShapes.length; ++i) {
      playerActionListener(i, ACTION_TYPES.moveDown);
    }
  });
  // ...

  return () => {
    stopGameClock();
    // ...
  };
};

The next step is to add some rules to the game world. We can check against these rules before performing any actions. In some cases, rules may trigger the game to emit a new shape. Let's define what the rules can do.

const RULE_ACTIONS = {
  BLOCK_ACTION: 1,
  CREATE_SHAPE: 2,
  END_THE_GAME: 3,
};

And then change the player action listener to check the rules.

const startWorld = (keymaps, renderer, ticker, ruleset, options) => {
  // ...
  const playerActionListener = (i, action) => {
    const activeShape = playerShapes[i][0];
    const simulated = Object.create(activeShape);
    action(simulated);
    for (let j = 0; j < ruleset.length; ++j) {
      const ruleFn = ruleset[j];
      const result = ruleFn(simulated, activeShape, playerShapes);
      if (result === RULE_ACTIONS.BLOCK_ACTION) {
        return;
      } else if (result === RULE_ACTIONS.CREATE_SHAPE) {
        createNewShape(i);
        return;
      } else if (result === RULE_ACTIONS.END_THE_GAME) {
        alert('GAME OVER!');
        stopWorld();
      }
    }
    action(activeShape);
    requestReRender();
  };
  const stopGameClock = ticker(() => {
    for (let i = 0; i < playerShapes.length; ++i) {
      playerActionListener(i, ACTION_TYPES.moveDown);
    }
  });
  const stopWorld = () => {
    stopGameClock();
    stopPlayerFns.forEach((fn) => fn());
  };
  // ...
  return stopWorld;
};

And add some rules to the game world.

const createGameRules = (options) => {
  const doesBBoxOverlap = (s1, s2) => {
    if (
      s1.x > s2.x + s2.type[0].length ||
      s1.x + s1.type[0].length < s2.x ||
      s1.y > s2.y + s2.type.length ||
      s1.y + s1.type.length < s2.y
    ) {
      return false;
    }
    return true;
  };
  const doesBlockOverlap = (s1, row, col, s2) => {
    if (!s1.type[row][col]) {
      return false;
    }
    const s2row = row + s1.y - s2.y;
    const s2col = col + s1.x - s2.x;
    if (s2.type[s2row] && s2.type[s2row][s2col]) {
      return true;
    }
    return false;
  };
  const getOtherShapes = function* (activeShape, playerShapes) {
    for (let i = 0; i < playerShapes.length; ++i) {
      const shapes = playerShapes[i];
      for (let j = 0; j < shapes.length; ++j) {
        const shape = shapes[j];
        if (shape === activeShape) {
          continue;
        }
        yield [shape, j === 0];
      }
    }
  };
  const isBlockedFromLeft = (simulated, activeShape, playerShapes) => {
    if (simulated.type === activeShape.type && simulated.x >= activeShape.x) {
      return null;
    }
    if (simulated.x < 0) {
      return RULE_ACTIONS.BLOCK_ACTION;
    }
    for (let [shape] of getOtherShapes(activeShape, playerShapes)) {
      if (!doesBBoxOverlap(simulated, shape)) {
        continue;
      }
      const col = 0;
      for (let row = 0; row < simulated.type.length; ++row) {
        if (doesBlockOverlap(simulated, row, col, shape)) {
          return RULE_ACTIONS.BLOCK_ACTION;
        }
      }
    }
    return null;
  };
  const isBlockedFromRight = (simulated, activeShape, playerShapes) => {
    if (simulated.type === activeShape.type && simulated.x <= activeShape.x) {
      return null;
    }
    if (simulated.x + simulated.type[0].length > options.worldWidth) {
      return RULE_ACTIONS.BLOCK_ACTION;
    }
    for (let [shape] of getOtherShapes(activeShape, playerShapes)) {
      if (!doesBBoxOverlap(simulated, shape)) {
        continue;
      }
      const col = simulated.type[0].length - 1;
      for (let row = 0; row < simulated.type.length; ++row) {
        if (doesBlockOverlap(simulated, row, col, shape)) {
          return RULE_ACTIONS.BLOCK_ACTION;
        }
      }
    }
    return null;
  };
  const isBlockedFromBottom = (simulated, activeShape, playerShapes) => {
    if (simulated.type === activeShape.type && simulated.y <= activeShape.y) {
      return null;
    }
    if (simulated.y + simulated.type.length > options.worldHeight) {
      return RULE_ACTIONS.CREATE_SHAPE;
    }
    for (let [shape, isActive] of getOtherShapes(activeShape, playerShapes)) {
      if (!doesBBoxOverlap(simulated, shape)) {
        continue;
      }
      const row = simulated.type.length - 1;
      for (let col = 0; col < simulated.type[0].length; ++col) {
        if (doesBlockOverlap(simulated, row, col, shape)) {
          if (simulated.y <= 0) {
            return RULE_ACTIONS.END_THE_GAME;
          }
          if (isActive) {
            return RULE_ACTIONS.BLOCK_ACTION;
          }
          return RULE_ACTIONS.CREATE_SHAPE;
        }
      }
    }
    return null;
  };
  return [isBlockedFromLeft, isBlockedFromRight, isBlockedFromBottom];
};

Are we there yet?

The code we have written so far brings us to this:

There's a lot of work left to do. In terms of gameplay, we should clear rows from the world if they are complete and keep track of how many rows the players have cleared so far. But let's stop it here for the MVP.