How to Make a Falling Blocks Game – Let’s Create a Tetris-like Puzzle Game (First Half)

Game

In this two-part series, we’ll create a minimalist Tetris-like falling block puzzle game. We won’t use any game engines or libraries; instead, we’ll make the game using only plain HTML and JavaScript.

This guide is for people who:

Want to try making a game but have no idea where to start.
Just want to make a simple game without diving into complex tools like Unity or Unreal Engine.

We’re targeting people with a basic understanding of programming.

What is Tetris?

Tetris is a famous falling block puzzle game that became popular in the late 1980s and 1990s. For more details, check out Wikipedia.。

テトリス - Wikipedia

What We Will Create

Among the elements that make up the gameplay of Tetris, we’ll implement the following: blocks falling, stacking up, and clearing a line when it’s completely filled horizontally.
You can use the left and right arrow keys to move the blocks. Press ‘d’ to speed up the falling blocks and ‘r’ to restart from the beginning.

Prerequisites

In this article, we won't cover the basic techniques used to create the game, such as:

  • How to draw images on the screen
  • How to handle user key input
  • How to manage the game state

If you want to learn more about these topics, please read the following articles.

Source Code

<html>
    <center>
        <canvas id="gameCanvas" width="240" height="440" style="border:1px solid #000000; background-color: #000;"></canvas>
    <script type="text/javascript" src="index.js"></script>
</html>
const canvas = document.getElementById('gameCanvas');
const context = canvas.getContext('2d');

const SCREEN_W = 10
const SCREEN_H = 20
const BLOCK_SIZE = 20

let screen = Array(SCREEN_H*SCREEN_W);

// position of block
let blockX = 5;
let blockY = 0;

const blockShape = [1,1,0,0,
                    1,1,0,0]

// Input event handler
document.addEventListener('keydown', keyDownHandler, false);

function keyDownHandler(event) {
    if (event.key === 'Left' || event.key === 'ArrowLeft') {
        if (!outOfArea(blockX-1, blockY, blockShape)
            && !blockHit(blockX-1, blockY, blockShape))
            blockX -= 1;
    } 

    if (event.key === 'Right' || event.key === 'ArrowRight') {
        if (!outOfArea(blockX+1, blockY, blockShape)
            && !blockHit(blockX+1, blockY, blockShape))
            blockX += 1;
    }

    if (event.key === 'd') {
        if (!outOfArea(blockX, blockY+1, blockShape)
            && !blockHit(blockX, blockY+1, blockShape))
            blockY += 1;
    }

    if (event.key == 'r') {
        init();
    }
}

function drawRect(x,y, block) {
    context.strokeStyle = '#fff';
    context.lineWidth = 2;
    context.strokeRect((x+1) * BLOCK_SIZE, (y+1) * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
    
    if (block==1) {
        context.fillStyle = '#fff'
        context.fillRect((x+1) * BLOCK_SIZE, (y+1) * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
    }
}

function drawScreen() {
    for (j = 0; j < SCREEN_H; j++) {
        for (i = 0; i < SCREEN_W; i++) {
            block = screen[ i + j*SCREEN_W ];
            drawRect(i, j, block);
        }
    }
}

function hasBrick(x,y) {
    return 0!=screen[x + y*SCREEN_W];
}

function doFuncOnShape(shape, f) {
    if (shape == undefined) return;
    for (j = 0; j < 2; j++) {
        for (i = 0; i < 4; i++) {
            if (shape[i+j*4]==1) {
                f(i, j)
            }
        }
    }
}

function outOfArea(x, y, shape) {
    let flg = false;
    doFuncOnShape(shape, function (i,j) {
        if (x+i < 0 || x+i >= SCREEN_W || y+i >= SCREEN_H)
            flg = true;
    });
    return flg;
}

function drawBlock(x, y, shape) {
    doFuncOnShape(shape, function (i,j) {
        drawRect(x+i, y+j, 1);
    });
}

function blockHit(x, y, shape) {
    // ブロックの形データ4 x 2を見ていく
    for (j = 0; j < 2; j++) {
        for (i = 0; i < 4; i++) {
            if (shape[i+j*4]==1) {
                if (hasBrick(x+i, y+j))
                    return true;
            }
        }
    }
    return false;
}

function putBlock(x, y, shape) {
    // check the 4x2 shape data of the block
    for (j = 0; j < 2; j++) {
        for (i = 0; i < 4; i++) {
            if (shape[i+j*4]==1) {
                setBrick(x+i, y+j, 1);
            }
        }
    }
}
function setBrick(x, y, block) {
    screen[x + y*SCREEN_W] = block;
}


function lineIsComplete(line) {
    for (i = 0; i < SCREEN_W; i++) {
        if (screen[i + line*SCREEN_W]==0)
            return false;
    }
    return true;
}

function checkLineComplete() {
    do {
        deleted_line = false;
        for (j = SCREEN_H-1; j >=0 ; j--) {
            if (lineIsComplete(j)) {
                deleteLine(j);
                j++;
                deleted_line = true;
            }
        }
    } while (deleted_line);
}

function deleteLine(line) {
    for (j = line-1; j >= 0; j--) {
        copyLine(j,j+1);
    }
}

function copyLine(src, tgt) {
    for (i = 0; i < SCREEN_W; i++) {
        screen[i + tgt*SCREEN_W] = screen[i + src*SCREEN_W];
    }
}


const WAIT_COUNT = 40;
let wait_count = 0;

function is_in_interval(count) {
    wait_count += 1;
    if (wait_count < count) {
        return true;
    }
    wait_count = 0;
    return false;
}

function update() {
    if (is_in_interval(WAIT_COUNT))
        return;

    if (blockHit(blockX, blockY+1, blockShape)) {
        putBlock(blockX, blockY, blockShape);
        blockY = 0;
        return;
    }

    checkLineComplete(); 

    blockY += 1; 
}

function draw_text_center(text, font='30px Consolas') {
    context.font = font;
    context.textAlign = 'left';
    text_w = context.measureText(text).width;
    context.fillText(text, canvas.width/2-text_w/2, canvas.height/2);
}

function init() {
    screen.fill(0);
}

function draw() {
    context.clearRect(0, 0, canvas.width, canvas.height);
    drawScreen();
    drawBlock(blockX, blockY, blockShape);
    update();
}

init();

setInterval(draw, 10);

Structure of the Screen

The game screen is made up of a 10x20 grid. To manage whether or not each cell contains a block, we use a 10x20 one-dimensional array as a buffer.

Defining the Screen

const SCREEN_W = 10
const SCREEN_H = 20
・・・
let screen = Array(SCREEN_H * SCREEN_W);

We'll use a 10x20 grid to represent the game screen. To manage whether a block is present in each cell, we'll use a 1-dimensional array.

screen[ 2 + 3 * SCREEN_W ] = 1; // place a block in the cell column 2, row 3

Following code is showing how to draw entire screen.

function drawScreen() {
    for (j = 0; j < SCREEN_H; j++) {
        for (i = 0; i < SCREEN_W; i++) {
            block = screen[ i + j*SCREEN_W ];
            drawBrick(i, j, block);
        }
    }
}

We extract the elements one by one from the first row and draw rectangles using drawRect(). If the block is 0, we draw a black rectangle.

function drawRect(x,y, block) {
    context.strokeStyle = '#fff';
    context.lineWidth = 2;
    context.strokeRect((x+1) * BLOCK_SIZE, (y+1) * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
    
    if (block==1) {
        context.fillStyle = '#fff'
        context.fillRect((x+1) * BLOCK_SIZE, (y+1) * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
    }
}

If it's 1, we draw a white-filled rectangle since a block is present.

Falling Blocks and Collision Detection

To implement falling blocks, we need position information.

let blockX = 5;
let blockY = 0;

We manage it as coordinates within the 10x20 screen, where X is the column number and Y is the row number.

Also, the shape of the block is important for collision detection. In this demo, we use square blocks occupying 2x2 cells. Here's the data structure representing this shape.

const blockShape = [1,1,0,0,
                    1,1,0,0]

In this demo, the block is in a 2x2 square shape, but the data structure defined in the source code is a 4x2 grid.

In Tetris, there are seven types of falling block shapes. To define all the shapes, a 4x2 grid is necessary. The definition of the seven block shapes and the rotation process will be covered in the next article.

When the block falls and touches the bottom boundary, it stops. We need the block's coordinates and shape data to make this determination.

We define the blockHit() function to check if the block has hit something. Inside this function, we use the hasBrick() function to check the content of a specific cell in the screen.

function blockHit(x, y, shape) {
    // check the 4x2 shape data of the block
    for (j = 0; j < 2; j++) {
        for (i = 0; i < 4; i++) {
            if (shape[i+j*4]==1) {
                if (hasBrick(x+i, y+j))
                    return true;
            }
        }
    }
    return false;
}

function hasBrick(x,y) {
    return 0!=screen[x + y*SCREEN_W];
}

In the hasBrick() function, we determine that there is a block if the specific cell in the screen is not 0.

It's a bit tricky here. Even if y is outside the bottom boundary area of the screen, hasBrick() returns true. The function can judge even if it's outside the bottom range.

For the 10x20 area, y is within the range of 0-19. When y is 20, it exceeds the index of the array. If you access an out-of-range array, undefined is returned. Therefore, 0 != undefined results in true.

Stacking Process

If the block can't fall any further, it stacks at that position. This process is implemented in the update() function.

function update() {
    ・・・

    if (blockHit(blockX, blockY+1, blockShape)) {
        putBlock(blockX, blockY, blockShape); // stack the block
        blockY = 0;
        return;
    }

  ・・・
}

If the block hits something one step down, the putBlock() function is called.

Here's the implementation of the putBlock() function. It sets the data in the screen buffer according to the shape of the 4x2 block.

function putBlock(x, y, shape) {
    // check the 4x2 shape data of the block
    for (j = 0; j < 2; j++) {
        for (i = 0; i < 4; i++) {
            if (shape[i+j*4]==1) {
                setBrick(x+i, y+j, 1);
            }
        }
    }
}
function setBrick(x, y, block) {
    screen[x + y*SCREEN_W] = block;
}

Line Clearing Process

So far, we've seen the process where blocks fall and stack when they hit something.

You can tell if there are stacked blocks by looking at the screen buffer.

It's easy to check if a line is complete. Just confirm if all elements in a row are 1. Here's the implementation.

function lineIsComplete(line) {
    for (i = 0; i < SCREEN_W; i++) {
        if (screen[i + line*SCREEN_W]==0)
            return false;
    }
    return true;
}

Algorithmically, it's more efficient to judge that the line is not complete if any element in the row is 0, which is how it's implemented.

When a line is complete, the entire line disappears, and the blocks stacked above fall down. Here's the implementation for that.

function checkLineComplete() {
    do {
        deleted_line = false;
        for (j = SCREEN_H-1; j >=0 ; j--) {
            if (lineIsComplete(j)) {
                deleteLine(j);
                j++;
                deleted_line = true;
            }
        }
    } while (deleted_line);
}

When a row is filled with 1s, that row is deleted and the rows above it are shifted down. This is handled by the deleteLine() function.

function deleteLine(line) {
    for (j = line-1; j >= 0; j--) {
        copyLine(j,j+1);
    }
}

function copyLine(src, tgt) {
    for (i = 0; i < SCREEN_W; i++) {
        screen[i + tgt*SCREEN_W] = screen[i + src*SCREEN_W];
    }
}

What deleteLine() does is copy the row above the one you want to delete onto itself. This process continues all the way to the top. Simply put, it's doing what is shown in the diagram below.

This process is repeated until there are no more lines filled with 1s.

A more efficient algorithm could be considered, but there’s a trade-off with the simplicity of the data structure. For now, this approach will do.

Next Steps

Now, we've implemented the following basic elements that make up the gameplay of Tetris:

  • Blocks fall.
  • Blocks stack.
  • If the line is full, the line disappears.

What's left is to increase the types of blocks and make them rotatable. In the next article, we'll implement these and aim to complete a minimalist Tetris-like game.


コメント

Copied title and URL