Tetris: Making The Game

2020, Aug 15
Link to project here.

In Tetris, players must complete lines by moving differently shaped pieces (tetrominoes), which descend onto the playing field. The completed lines disappear and grant the player points, and the player can proceed to fill the vacated spaces. The game ends when the playing field is filled. The longer the player can delay this inevitable outcome, the higher their score will be.

Table Of Contents


Tetrominoes


Node

A Node is the base object used to represent tetrominoes as blocks in the play area. It is essentially unchanged from when I used it in the Snake app aside from the addition of tracking the color of the node.

const Node = (i, j, color=null) => {
    const node = {}

    node.i = i
    node.j = j
    node.color = color

    node.inBounds = (ni, nj) => node.i>=0 && node.i<ni && node.j>=0 && node.j<nj
    node.eq = n => node.i == n.i && node.j == n.j
    node.sum = n => Tetromino.Node(node.i + n.i, node.j + n.j)
    node.sub = n => Tetromino.Node(node.i - n.i, node.j - n.j)

    return node
  }

AbstractPiece

Tetrominoes are created as Pieces and then converted to an array of Nodes as needed. This is mainly to make tracking positions and orientation more straightforward. The AbstractPiece class contains all attributes and behaviors that are common to every tetromino. The main attributes are:

  • i - The column index.
  • j - The row index.
  • rot - The rotation index.

Colors are preset for each tetromino and are defined in their class implementation. Using these values, the Piece object will decide how to generate the actual Node representation of the tetromino via the get() method.

get() Method

Here we have another thing that a concrete class must supply, the piece.rotations array. This is an array of length 4 that defines the position of each filled cell in each rotation state for the tetromino, instead of dealing with on the fly matrix rotations. These are all based on the image below which I found in the amazingly helpful Tetris wiki.

The left side shapes represent the state which each shape takes when it spawns, which is also index 0 in the piece.rotations array. The indices increase moving to the right where the shape rotates clockwise. Here is the rotation array of the J tetromino as an example:

const J_ROTATIONS = [
  [
    [0,0], 
    [0,1], [1,1], [2,1]
  ],[
    [1,0],[2,0],
    [1,1], 
    [1,2]
  ],[
    [0,1], [1,1], [2,1], 
                  [2,2]
  ],[
          [1,0], 
          [1,1], 
    [0,2],[1,2],
  ],
]

The piece.get() method selects a rotation state based on the attribute piece.rot and then generates a Node array with absolute positioning based on the piece.i and piece.j.

piece.get = () => {
  return piece
    .rotations[rot]
    .map(([i,j]) => Tetromino.Node(i+piece.i, j+piece.j, piece.color))
}

isValid() Method

This method is used to check the validity if a Piece given the state of the play area which is a 2D Array called a Stack.

piece.isValid = stack => piece.isInBounds(stack[0].length, stack.length) && !piece.isCollided(stack)

As you can see, it checks wether it is still within the bounds of the game or has collided with the stack. Instead of looking at the position of the Piece, they inspect the Node array that is the result of the piece.get() method.

piece.isInBounds = (ni, nj) => piece.get().every(node => node.inBounds(ni, nj))
piece.isCollided = stack => piece.get().some(node => stack[node.j][node.i]!=null)

Position and Rotation Methods

Pieces need to be able to move around in the play area as input from the player is received or as time passes. The piece.next() method generates a copy of the piece displaced by the given i, j and set to rotation rot.

piece.next = (i=0, j=1, rot=piece.rot) => {
  const p = piece.cons(piece.i+i, piece.j+j, rot)
  return p
}

If no parameters are provided, it moves the piece down by 1, which is what happens during an update without any player input. This is also reused in the piece.left() and piece.right() methods below:

piece.left = stack => {
  let p = piece.next(-1, 0)
  return p.isValid(stack) ? p : piece.next(0, 0)
}

piece.right = stack => {
  let p = piece.next(1, 0)
  return p.isValid(stack) ? p : piece.next(0, 0)
}

These methods only allow a left shift or right shift of a Piece when it is legal to do so. Additionally, a Piece can also be rotated clockwise. Rather than simply disallowing a rotation if it produces an invalid state, each Piece can "kick" itself off the wall in order to perform the intended rotation. These are known as Wall Kicks and is baked in to the piece.cw() method. The method tries each kick in the supplied order and keeps the first one that is valid, otherwise it will not rotate the piece.

piece.cw = stack => {
  let rot = piece.rot>=piece.rotations.length-1 ? 0 : piece.rot+1
  let kicks = piece.cwKicks[piece.rot]
  for(let i=0; i<kicks.length; i++) {
    let p = piece.next(kicks[i][0], kicks[i][1], rot)
    if(p.isValid(stack)) return p
  }
  return piece.next(0, 0)
}

All Pieces share the same kick data, with the exception of the "I" and "O" tetromino. All of this data can be found on the Tetris wiki.

const CW_KICKS = [
  [[0,0], [-1,0], [-1, 1], [0,-2], [-1,-2]],
  [[0,0], [1,0], [1,-1], [0,2], [1,2]],
  [[0,0], [1,0], [1,1], [0,-2], [1,-2]],
  [[0,0], [-1,0], [-1,-1], [0,2], [-1,2]]
]

Note that each kick is applied to the position of the Piece object, and changes based on the rotation state and direction. Because I only implemented rotation in the clockwise direction, I can omit others.

Game


The game logic and requirements are all laid out in the Tetris Guidelines, making development quite straightforward.

Stack

The stack is a 2D array that keeps track of Tetromino segments that have been locked in place. This can be a relatively simple data structure because once the pieces are locked in place, the only thing that it has to remember is the color of each cell. That's why empty cells are null and filled cells contain a html color depending on the color of the piece that was locked in there, and adding a piece to the cell is simply adding its color to the correct coorinates.

  addPiece: (piece, stack) => piece.get().forEach(node => stack[node.j][node.i] = node.color),

  removePiece: (piece, stack) => piece.get().forEach(node => stack[node.j][node.i] = null),

State

This is the object used to track the game progression and contains the following attributes:

  1. current - The Tetromino currently being interacted with by the player
  2. stack - a 2D array that keeps track of Tetromino segments that have been locked in place.
  3. ghost - "state.current" shifted down to the lowest point in the stack
  4. gravity - Boolean where true indicates that the next update will be for computing gravity. Typically set the true after lines have been cleared.
  5. nextPieces - An Array of the next 3 Tetrominos that will be played.
  6. hold - Piece that is being temporarily held.
  7. gameOver - Boolean where true if no new pieces can spawn.
  8. score - Number of lines cleared.

A new state can be generated using the getNewState() function.

const getNewState = (ni, nj) => {
  // Create a new state
  let newState = {
    hold: null,
    justHeld: false,
    gravity: false,
    stack: utils.mkFill(ni, nj, null),
    nextPieces: Game.getRandomPieces(),
    gameOver: false,
    score: 0
  }
  // Add current and ghost
  Game.updateCurrent(Game.getNextPiece(newState), newState)
  return newState
}

getRandomPieces() Function

The game randomly generates its sequence using the generically named Random Generator. It is essentially just like putting all 7 pieces into a bag and then blindly picking them out without replacement. Once all pieces have been taken out of the bag, they are all put back in and the process is repeated.

Here I generate an array containing one of each piece and then shuffle the array before returning the sequence.

const getRandomPieces = () => {
  let seq = Game.tetrominoFactories
    .map(cons => cons==Tetromino.I ? 
        cons(Game.SPAWN_LOC[0], Game.SPAWN_LOC[1]-1) : 
        cons(Game.SPAWN_LOC[0], Game.SPAWN_LOC[1]))
  utils.shuffle(seq)
  return seq
}

updateCurrent() Function

This just updates the given state object with the given Piece. It also updates the ghost to match the current piece.

const updateCurrent = (piece, state) => {
  state.current = piece
  state.ghost = piece != null ? Game.getGhost(state.current, state.stack) : null
}

getGhost() Function

The ghost is a representation of where a tetromino will land if allowed to drop into the playfield. This function initially placed the ghost that the first valid location for a piece, however it did not account for any blockages.

Now it check up to the height where Piece currently is and exits early. This being the case, this would probably be better starting from the Piece itself rather than from the bottom of the stack. I might change this later.

const getGhost = (piece, stack) => {
  let newPiece = null
  for(let j = stack.length-1; j>=0; j--) {
    if (j<=piece.j) break
    p = piece.cons(piece.i, j, piece.rot)
    if (p.isValid(stack) && newPiece==null) newPiece = p
    else if (!p.isValid(stack)) newPiece = null
  }
  return newPiece
}

getNextPiece() Function

This retrieves the next piece in the sequence. Because this mutates the state.nextPieces when it is called, this should only be used when the Piece is actually needed.

This function also ensures that the game never runs out of pieces to use by automatically replenishing the state.nextPieces if it falls below the threshold set by Game.PREVIEW_LIMIT.

const getNextPiece = state => {
  if (state.nextPieces.length <= Game.PREVIEW_LIMIT) {
    state.nextPieces.push(...Game.getRandomPieces())
  }
  return state.nextPieces.shift()
}

holdPiece() Function

In Tetris, the player can press a button to send the falling tetrimino to the hold box, and any tetrimino that had been in the hold box moves to the top of the screen and begins falling. If the hold box is empty, then the next piece in the sequence is taken.

This move is only allowed once per piece. Because of the unique shape of the I tetromino, its spawn location has to be adjusted to visually match the other pieces.

const holdPiece = state => {
  if (state.current!=null && !state.justHeld) {
    if(state.hold == null) {
      state.hold = state.current
      Game.updateCurrent(Game.getNextPiece(state), state)
    } else {
      let temp = state.current
      Game.updateCurrent(state.hold, state)
      state.hold = temp
    }
    state.justHeld = true
    state.hold.i = state.hold.cons==Tetromino.I ? Game.SPAWN_LOC[0] :  Game.SPAWN_LOC[0]
    state.hold.j = state.hold.cons==Tetromino.I ? Game.SPAWN_LOC[1]-1 :  Game.SPAWN_LOC[1]
    state.hold.rot = 0
  }
}

Line Clearing

When a row is completely filled, it should be removed from the stack.

const clearLines = stack => {
  let nLines = 0
  for(let j=stack.length-1; j>=0; j--) {
    let nFilled = Game.countFilled(stack[j])
    if (nFilled == 0) break
    else if (nFilled == stack[0].length) {
      stack[j].forEach((_, i) => stack[j][i] = null)
      nLines += 1
    }
  }
  return nLines
}

Once the full lines have been cleared, the stack is then compressed via "gravity". Here I keep track the number of lines compressed going from bottom to top so that I only need to do a single pass for each line.

const processGravity = stack => {
  let nEmpty = 0
  for(let j=stack.length-1; j>=0; j--) {
    let nFilled = Game.countFilled(stack[j])
    if (nFilled==0) nEmpty += 1
    else if (nFilled>0 && nEmpty>0) {
      stack[j].forEach((v,i) => {
        stack[j+nEmpty][i] = v
        stack[j][i] = null
      })
    }
  }
}

Both these functions could probably be run in the same frame, but to make it clear to the viewer which lines were cleared it is better that they be processed separately.