Creating a 3D Tetris Game for Dummies Like Me — VIII

Oleksii Koshkin
6 min readJun 25, 2023

--

TL;DR: live demo or the advanced game + some shameless advertising wrapper.

Level: for beginners.
See detailed links and table of contents at the bottom of the article.

The last… well, almost… part! I still need to add some control to the game because not allowing the user to move the shape is a bit harsh. Nice but harsh.

The most simple things are move to the left and to the right. Pretty obvious, I just need to check if there is room to move in all the falling cells, and if so, then move.

// App.svelte
import {
removeFilledLines,
fallDown,
moveFigureLeft,
moveFigureRight,
} from "./game-utils";
...
function processKeysDown(ev) {
...
if (e === "ArrowLeft") {
moveLeft();
}
if (e === "ArrowRight") {
moveRight();
}
...
}

function moveLeft() {
if (paused || !started || gameOver) {
return;
}
moveFigureLeft(GameField);
tickManager.immediateRestart();
}

function moveRight() {
if (paused || !started || gameOver) {
return;
}
moveFigureRight(GameField);
tickManager.immediateRestart();
}

And implementation:

// game-utils.ts

function traverseBottomTopRight(field, callback: (row, col) => void) {
for (let row = 0; row < field.length; row++) {
for (let col = field[row].length - 1; col >= 0; col--) {
callback(row, col);
}
}
}

export function moveFigureLeft(GameField) {
let canMove = true;

traverseBottomTop(GameField, (row, col) => {
const block = GameField[row][col];

if (block !== 1) {
return;
}

if (col === 0 || GameField[row][col - 1] === 2) {
canMove = false;
}
});

if (!canMove) {
return;
}

traverseBottomTop(GameField, (row, col) => {
const block = GameField[row][col];

if (block !== 1) {
return;
}

GameField[row][col - 1] = 1;
GameField[row][col] = 0;
});
}

export function moveFigureRight(GameField) {
let canMove = true;

const width = GameField[0].length;

traverseBottomTop(GameField, (row, col) => {
const block = GameField[row][col];

if (block !== 1) {
return;
}

if (col === width - 1 || GameField[row][col + 1] === 2) {
canMove = false;
}
});

if (!canMove) {
return;
}

traverseBottomTopRight(GameField, (row, col) => {
const block = GameField[row][col];

if (block !== 1) {
return;
}

GameField[row][col + 1] = 1;
GameField[row][col] = 0;
});
}

Very, very straightforward code. Pity the rotation isn’t that simple.

First I’m adding the keyboard event processing:

// App.svelte
import {
removeFilledLines,
fallDown,
moveFigureLeft,
moveFigureRight,
rotateFigure,
} from "./game-utils";
...
function processKeysDown(ev) {
...
if (e === "ArrowUp") {
// rotate
rotate()
}
...
}

...

function rotate() {
if (paused || !started || gameOver) {
return;
}

if (rotateFigure(GameField)) {
tickManager.immediateRestart();
}
}

and implementation:

export function rotateFigure(field) {
// 1. scan for figure
let figure = [];
let minIdx = field[0].length;
let maxIdx = 0;
let firstRow = -1;

for (let row = field.length - 1; row >= 0; row--) {
let line = '';
for (let col = 0; col < field[row].length; col++) {
if (field[row][col] === 1) {
line += '#';
if (col < minIdx) {
minIdx = col;
}
if (col > maxIdx) {
maxIdx = col;
}
if (firstRow < row) {
firstRow = row;
}
} else {
line += ' ';
}
}
if (line.trim()) {
figure.push(line);
}
}
const rawWidth = maxIdx - minIdx + 1;
const rawHeight = figure.length;
if (rawWidth === rawHeight) {
// square, nothing to do
return false;
}

// 2. Transpond figure
figure = figure.map(line => line.substring(minIdx, minIdx + rawWidth));
if (firstRow > 22) {
return false;
}

const newFigure = [];
for (let i = 0; i < rawWidth; i++) {
newFigure[i] = new Array(rawHeight);
}

for (let row = 0; row < newFigure.length; row++) {
for (let col = 0; col < newFigure[row].length; col++) {
if (figure[col][row] !== ' ') {
newFigure[newFigure.length - row - 1][col] = '#';
}
}
}

// 3. Cleanup copy of field...
const tempField = copyField(field);

traverseBottomTop(tempField, (row, col) => {
if (tempField[row][col] === 1) {
tempField[row][col] = 0;
}
});

// 4. ...and put new figure there, if possible
let allow = true;
let nRow = 0;

for (let row = firstRow; row > firstRow - newFigure.length; row--) {
for (let col = minIdx; col < minIdx + rawHeight; col++) {
const nCol = col - minIdx;

if (nRow >= 0 && nRow < newFigure.length && nCol >= 0 && nCol < rawHeight) {
if (row < 0 || col < 0 || col >= tempField[0].length) {
allow = false;
}
if (newFigure[nRow][nCol]) {
if (tempField[row][col] === 2) {
allow = false;
}
if (allow) {
tempField[row][col] = 1;
}
}
}
}
nRow++;
}

// 5. If allowed, copy transponded figure to the real field
if (!allow) {
return false;
}
traverseBottomTop(field, (row, col) => {
if (tempField[row][col]) {
field[row][col] = tempField[row][col];
} else {
field[row][col] = 0;
}
});
return true;
}

export function createGameField() {
const field = new Array<Array10>(24) as TGameField;
for (let row = 0; row < field.length; row++) {
if (!field[row]) {
field[row] = new Array<number>(10) as Array10;
}
for (let col = 0; col < field[row].length; col++) {
field[row][col] = 0;
}
}
return field;
}

function copyField(GameField) {
const tempField = createGameField();

traverseBottomTop(GameField, (row, col) => {
if (GameField[row][col]) {
tempField[row][col] = GameField[row][col];
}
});
return tempField;
}

Uh oh.

Well, let’s explain.

First, I extract a piece from the playing field and build its string representation. To do this, I scan the field and place the corresponding falling blocks (1) on lines and spaces if no block falls on that position. In other words,

Detecting the figure

Then trim extra spaces.

That’s because I don’t keep track of the figure: after being added to the playing field, it’s just a set of falling blocks.

Then I transpose the figure. I create new array with swapped measures (i.e., 4x1 instead of 1x4 for I):

Transposing, or rotating the array.

And the most dirty trick: I create a copy of the playing field, remove all falling blocks from it and try to put a new transposed figure in the original coordinates. If I can’t (too close to a wall or a solid block interferes), I reject the operation. If I can, I replace the original field with a copy that contains the transposed figure.

Yes, I also laughed for a long time when I came up with this method, but then I thought — what the hell, why not? Works with any configuration of falling blocks and does not depend on anything, only direct processing of the playing field, I don’t care about performance and optimisations… leave it as is.

Everything works

The result of this article: all the game logic is done. Almost completed! Just need to add some sounds and play around with the fonts a bit…

Here is the GitHub repository with a snapshot of the stage.

To be continued … I promise, there is only one part left.

--

--

Oleksii Koshkin

AWS certified Solution Architect at GlobalLogic/Hitachi