December 29, 2020

Coding challenge: Tetris

As my first coding challenge I chose to develop the famous game Tetris with plain typescript. The main goal is to program the game as straightforward and clean as possible and to rebuild the game without looking at other examples of implementations.

👾 Link to Github

🎮 Link to Demo

To make our work a little bit easier, we set up our project with node, npm, and webpack. This helps us to uglify, minify and pack our code into one single bundle. At the same time, webpack allows us to hot-reload our project while developing. Rxjs is also one of the npm-dependencies, this will be used for our event handling.

Introduction

Alright, let's dive in! Tetris is a 2-dimensional coordinates based game that challenges the user to arrange different shapes into a horizontal line which removes the line from the coordinate system. 7 different shapes will one by one and randomly appear on top of the screen and fall down until they collide with other shapes or reach the bottom of the coordinate system.

The player can move the shape horizontally with the arrow-keys on the keyboard, rotate the shape with the up-key, and move the shape down faster with the down-key. The shape itself already has a downward velocity to add difficulty to the game.

The Grid

The virtual grid we create is two-dimensional and consists of GridPointers.

export class GridPointer {
    public x: number;
    public y: number;
    public color: string;
    constructor(x: number, y: number) { ... }
  ...
}

The origin is on the top left with the value x: 0; y: 0. It is handled by the class Playground and is only virtual because the output is decoupled and generated by another class. The Playground also forwards interactions to the shape, detects collisions, and if a shape is out of bounds.

Shapes

The game consists of 7 shapes. shapes When I started to program the shapes, I couldn't think of an algorithm to rotate the shapes, because most of the shapes have no center (e.g. 4x4). Therefore each shape has different rotations as an array. Each rotation has its own grid with the origin on the top left.

Here is an example of the L-Shapes rotations:

/*
LShape:
1:  x  o  o     2:  x  x  x     3:  x  o  x     4:  o  x  x
    x  o  x         o  o  o         x  o  x         o  o  o
    x  o  x         x  x  o         o  o  x         x  x  x
*/
rotations: [
    [ new GridPointer(1, 0), new GridPointer(2, 0),
      new GridPointer(1, 1), new GridPointer(1, 2)],
    [ new GridPointer(0, 1), new GridPointer(1, 1),
      new GridPointer(2, 1), new GridPointer(2, 2)],
    [ new GridPointer(1, 0), new GridPointer(1, 1),
      new GridPointer(1, 2), new GridPointer(0, 2)],
    [ new GridPointer(0, 0), new GridPointer(0, 1),
      new GridPointer(1, 1), new GridPointer(2, 1)]
]

To insert a new shape on the playground, we have to calculate the top center position.

const fromLeft = Math.floor((tsConfig.cols - (this.size)) / 2);
this.pointer = new GridPointer(fromLeft, - (this.size - 2));

this.size is the size of the shape (e.g. 4). We insert the shape slightly above the playground, visible but not entirely. We calculate the position of the different points of the shape based on the origin pointer Shape.pointer.

To move the shape horizontally or downwards, we simply move the origin pointer by the x or y-axis.

We can chain multiple operations e.g. shape.copy().moveLeft() by returning the entire object after each operation. This makes our code easier to read.

public moveDown(): ShapeContainer {
    this.pointer.moveDown();
    return this;
}

The copy function clones the object with all its properties. This will help us to modify the object and detect collisions without modifying the original shape.

public copy(): ShapeContainer {
    const newShape = new ShapeContainer(this.rotations, this.size, this.color);
    newShape.currentRotation = this.currentRotation;
    newShape.pointer = this.pointer.copy();
    return newShape;
}

Events

Multiple events can trigger a change on the playground:

  • Move down/left/right (user-induced)
  • Move down (game)
  • rotate (user-induced)
  • Move down/left/right interval (user-induced, key pressed)

The events are mainly managed by rxjs and browser-native intervals. The keyboard events are captured by:

this.keyEvents = merge(fromEvent(document, 'keydown'), fromEvent(document, 'keyup')).pipe(
    distinctUntilChanged((a, b) => a.code === b.code && a.type === b.type)
);

Those events will only fire the callback if the code and type are distinct. This means keydown combined with arrowLeft for example if held down will only fire once.

We attach the keyEvents to a function, that will fire the event instantaneously and fire it in an interval. The event for example could be this.playground.moveLeft(). Which will lead to the shape on the playground being displaced.

private startEventAndInterval(event: any): void {
    event();
    this.tick$.next(true);
    this.keyPressedInterval = setInterval(() => {
        event();
        this.tick$.next(true);
    }, 150);
}

You might wonder what the snippet this.tick$.next(true); does. Every time there is an event fired that changes or affects the playground we have to emit a next to all observers of the tick subject.

public tick(): Observable<boolean> {
    return this.tick$.asObservable();
}

The output will be generated every time there is a tick.

Collision detection

There are two types of collisions we have to detect:

  • collision with other shapes (at the bottom)
  • collision with the frame of the playground

To detect the collision with other shapes on the playground we can simply compare all the pointers on the playground with all the pointers of the shape:

const collision = shape.calculateCoordinates().find((pointer: GridPointer) => {
    return !!this.grid.find((playgroundPointer: GridPointer) => {
        return playgroundPointer.x === pointer.x && playgroundPointer.y === pointer.y;
    })
});

We simply compare the pointers with the number of columns and rows of the playground to detect if the shape is out of the frame:

const outOfBounds = shape.calculateCoordinates().find((pointer: GridPointer) => {
    return (
        (pointer.x < 0) ||
        (pointer.x >= tsConfig.cols) ||
        (pointer.y >= tsConfig.rows)
    );
});

If those two checks don't pass, we cannot move the shape in that position.

Output

There are multiple ways to draw the playground. By far the most intuitive and resource-efficient is to use a HTML-canvas. You could also draw the playground by using HTML containers only. In this game, though we use the CanvasRenderingContext2D (canvas in typescript).

Let's draw the playground grid first:

const boxHeight = Math.floor(this.height / tsConfig.rows);
const boxWidth = Math.floor(this.width / tsConfig.cols);
for (let r = 0; r < tsConfig.rows; r++) {
    for (let c = 0; c < tsConfig.cols; c++) {
        const positionX = boxWidth * c;
        const positionY = boxHeight * r;
        this.context.strokeRect(positionX, positionY, boxWidth, boxHeight);
    }
}

The different pointers (and therefore shapes) are drawn seperately on the grid with this function:

pointers.forEach((pointer: GridPointer) => {
    this.context.globalAlpha = pointer.alpha ? pointer.alpha : 1;
    this.context.fillStyle = pointer.color ? pointer.color : tsConfig.defaultFillStyle;
    this.context.fillRect(boxWidth * pointer.x, boxHeight * pointer.y, boxWidth, boxHeight);
});

In each draw iteration (triggered by the tick observable), we will redraw the entire grid and all shapes.

Prediction

Finally, we want to show the user a prediction, where the shape will fall if no interaction is taken. This usually (in other Tetris games) is shown as a slightly opaque shape on the bottom. tstris-prediction

This can be achieved by cloning the current shape and moving it down until it collides or is out of bounds. At last, we apply an alpha value to the pointers which will make it opaque.

private getPrediction(): Array<GridPointer> {
    const prediction = this.shape.copy();
    while (!this.detectCollisionOrOutOfBounds(prediction.copy().moveDown())) {
        prediction.moveDown();
    }
    return prediction.calculateCoordinates().map((pointer: GridPointer) => {
        pointer.alpha = 0.3;
        return pointer;
    });
}

Et voilà! Our Tetris in typescript is finished. I hope I managed to explain its core components and you didn't fall asleep reading it :)

Made with ♥️ and  Benjamin Mathieu, 2020