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