Tetris Game

By adamlubek

This recipe demonstrates RxJS implementation of Tetris game.

Example Code

( StackBlitz )

index.ts

// RxJS v6+
import { fromEvent, of, interval, combineLatest } from 'rxjs';
import { finalize, map, pluck, scan, startWith, takeWhile, tap } from 'rxjs/operators';
import { score, randomBrick, clearGame, initialState } from './game';
import { render, renderGameOver } from './html-renderer';
import { handleKeyPress, resetKey } from './keyboard';
import { collide } from './collision';
import { rotate } from './rotation';
import { BRICK } from './constants';
import { State, Brick, Key } from './interfaces';

const player$ = combineLatest(
  of(randomBrick()),
  of({ code: '' }),
  fromEvent(document, 'keyup').pipe(startWith({ code: undefined }), pluck('code'))
).pipe(
  map(([brick, key, keyCode]: [Brick, Key, string]) => (key.code = keyCode, [brick, key]))
);

const state$ = interval(1000)
  .pipe(
    scan<number, State>((state, _) => (state.x++ , state), initialState)
  );

const game$ = combineLatest(state$, player$)
  .pipe(
    scan<[State, [Brick, Key]], [State, [Brick, Key]]>(
      ([state, [brick, key]]) => (
        state = handleKeyPress(state, brick, key),
        (([newState, rotatedBrick]: [State, Brick]) => (
          state = newState,
          brick = rotatedBrick
        ))(rotate(state, brick, key)),
        (([newState, collidedBrick]: [State, Brick]) => (
          state = newState,
          brick = collidedBrick
        ))(collide(state, brick)),
        state = score(state),
        resetKey(key),
        [state, [brick, key]]
      )),
    tap(([state, [brick, key]]) => render(state, brick)),
    takeWhile(([state, [brick, key]]) => !state.game[1].some(c => c === BRICK)),
    finalize(renderGameOver)
  );

game$.subscribe();

game.ts

import { GAME_SIZE, EMPTY, BRICK } from './constants';
import { State } from './interfaces';

const bricks = [
  [[0, 0, 0], [1, 1, 1], [0, 0, 0]],
  [[1, 1, 1], [0, 1, 0], [0, 1, 0]],
  [[0, 1, 1], [0, 1, 0], [0, 1, 0]],
  [[1, 1, 0], [0, 1, 0], [0, 1, 0]],
  [[1, 1, 0], [1, 1, 0], [0, 0, 0]]
]

export const clearGame = () => Array(GAME_SIZE).fill(EMPTY).map(e => Array(GAME_SIZE).fill(EMPTY));
export const updatePosition = (position: number, column: number) => position === 0 ? column : position;
export const validGame = (game: number[][]) => game.map(r => r.filter((_, i) => i < GAME_SIZE));
export const validBrick = (brick: number[][]) => brick.filter(e => e.some(b => b === BRICK));
export const randomBrick = () => bricks[Math.floor(Math.random() * bricks.length)];

export const score = (state: State): State => (scoreIndex => (
  scoreIndex > -1
    ? (
      state.score += 1,
      state.game.splice(scoreIndex, 1),
      state.game = [Array(GAME_SIZE).fill(EMPTY), ...state.game],
      state
    )
    : state
))(state.game.findIndex(e => e.every(e => e === BRICK)));

export const initialState = {
  game: clearGame(),
  x: 0,
  y: 0,
  score: 0
};

collision.ts

import { GAME_SIZE, BRICK, EMPTY } from './constants';
import { validBrick, validGame, updatePosition, randomBrick } from './game';
import { State, Brick } from './interfaces';

const isGoingToLevelWithExistingBricks = (state: State, brick: Brick): boolean => {
  const gameHeight = state.game.findIndex(r => r.some(c => c === BRICK));
  const brickBottomX = state.x + brick.length - 1;
  return gameHeight > -1 && brickBottomX + 1 > gameHeight;
}

const areAnyBricksColliding = (state: State, brick: Brick): boolean =>
  validBrick(brick).some((r, i) => r.some((c, j) =>
    c === EMPTY
      ? false
      : ((x, y) => state.game[x][y] === c)(i + state.x, j + state.y)
  ));

const collideBrick = (state: State, brick: Brick, isGoingToCollide: boolean): State => {
  const xOffset = isGoingToCollide ? 1 : 0;
  validBrick(brick).forEach((r, i) => {
    r.forEach((c, j) =>
      state.game[i + state.x - xOffset][j + state.y] =
      updatePosition(state.game[i + state.x - xOffset][j + state.y], c)
    );
  });
  state.game = validGame(state.game);
  state.x = 0;
  state.y = (GAME_SIZE / 2) - 1;
  return state;
}

export const collide = (state: State, brick: Brick): [State, Brick] => {
  const isGoingToCollide =
    isGoingToLevelWithExistingBricks(state, brick) &&
    areAnyBricksColliding(state, brick);

  const isOnBottom = state.x + validBrick(brick).length > GAME_SIZE- 1;

  if (isGoingToCollide || isOnBottom) {
    state = collideBrick(state, brick, isGoingToCollide);
    brick = randomBrick();
  }

  return [state, brick];
}

rotation.ts

import { GAME_SIZE, BRICK_SIZE, EMPTY } from './constants';
import { State, Brick, Key } from './interfaces';

const rightOffsetAfterRotation = (state: State, brick: Brick, rotatedBrick: Brick) =>
  (state.y + rotatedBrick.length === GAME_SIZE + 1) && brick.every(e => e[2] === EMPTY) ? 1 : 0;

const leftOffsetAfterRotation = (game: State) => game.y < 0 ? 1 : 0;
const emptyBrick = (): Brick => Array(BRICK_SIZE).fill(EMPTY).map(e => Array(BRICK_SIZE).fill(EMPTY));

const rotateBrick = (state: State, brick: Brick, rotatedBrick: Brick): [State, Brick] => (
  brick.forEach((r, i) => r.forEach((c, j) => rotatedBrick[j][brick[0].length - 1 - i] = c)),
  state.y -= rightOffsetAfterRotation(state, brick, rotatedBrick),
  state.y += leftOffsetAfterRotation(state),
  [state, rotatedBrick]
)

export const rotate = (state: State, brick: Brick, key: Key): [State, Brick] =>
  key.code === 'ArrowUp' ? rotateBrick(state, brick, emptyBrick()) : [state, brick]

keyboard.ts

import { GAME_SIZE } from './constants';
import { State, Brick, Key } from './interfaces';

const xOffset = (brick: Brick, columnIndex: number) => brick.every(e => e[columnIndex] === 0) ? 1 : 0;

export const handleKeyPress = (state: State, brick: Brick, key: Key): State => (
  state.x += key.code === 'ArrowDown'
    ? 1
    : 0,
  state.y += key.code === 'ArrowLeft' && state.y > 0 - xOffset(brick, 0)
    ? -1
    : key.code === 'ArrowRight' && state.y < GAME_SIZE - 3 + xOffset(brick, 2)
      ? 1
      : 0,
  state
);

export const resetKey = key => key.code = undefined;

html-renderer.ts

import { BRICK } from './constants';
import { State, Brick } from './interfaces';
import { updatePosition, validGame, validBrick, clearGame } from './game';

const createElem = (column: number): HTMLElement => (elem =>
  (
    elem.style.display = 'inline-block',
    elem.style.marginLeft = '3px',
    elem.style.height = '6px',
    elem.style.width = '6px',
    elem.style['background-color'] = column === BRICK
      ? 'green'
      : 'aliceblue',
    elem
  ))(document.createElement('div'))

export const render = (state: State, brick: Brick): void => {
  const gameFrame = clearGame();

  state.game.forEach((r, i) => r.forEach((c, j) => gameFrame[i][j] = c));
  validBrick(brick).forEach((r, i) =>
    r.forEach((c, j) => gameFrame[i + state.x][j + state.y] =
      updatePosition(gameFrame[i + state.x][j + state.y], c)));

  document.body.innerHTML = `score: ${state.score} <br/>`;
  validGame(gameFrame).forEach(r => {
    const rowContainer = document.createElement('div');
    r.forEach(c => rowContainer.appendChild(createElem(c)));
    document.body.appendChild(rowContainer);
  });
}

export const renderGameOver = () => document.body.innerHTML += '<br/>GAME OVER!';

interfaces.ts

export interface State {
  game: number[][];
  x: number;
  y: number;
  score: number;
}

export interface Key {
  code: string;
}

export type Brick = number[][];

constants.ts

export const GAME_SIZE = 10;
export const BRICK_SIZE = 3;
export const EMPTY = 0;
export const BRICK = 1;

Operators Used

Last updated