Tetris Game

By adamlubek

This recipe demonstrates RxJS implementation of Tetris game.

Ultimate RxJS

Example Code

( StackBlitz )

Tetris Game

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