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

Oleksii Koshkin
12 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.

Gameplay! Finally.

I will start with keyboard processing. I need to be able to start the game… then pause and interrupt, so that makes sense.

In App.svelte I wrote:

<script lang="ts">
import { onMount, onDestroy } from "svelte";
import GameOverBanner from "./banners/GameOverBanner.svelte";
import PauseBanner from "./banners/PauseBanner.svelte";
import StartBanner from "./banners/StartBanner.svelte";
import Keys from "./components/Keys.svelte";
import Next from "./components/Next.svelte";
import Scene from "./components/Scene.svelte";
import type { Array10, TGameField } from "./components/types";

let started = false; // is game runs?
let paused = false; // is pause state?
let gameOver = false; // is game finished?
let sound = true; // is sound enabled?

let level = 1; // current level
let score = 0; // initial score
let nextFigure = ''; // next figure

// create an empty game field
const GameField: TGameField = new Array<Array10>(24) as TGameField;
fillField(0);

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

onMount(() => {
document.addEventListener("keydown", processKeysDown);
document.addEventListener("keyup", processKeysUp);
waitGame();
});

onDestroy(() => {
document.removeEventListener("keydown", processKeysDown);
document.removeEventListener("keyup", processKeysUp);
});

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

function toggleSound() {
if (!started || gameOver) {
return;
}
sound = !sound;
}

function startGame() {
if (started || gameOver) {
return;
}
started = true;
paused = false;
gameOver = false;
level = 1;
score = 0;
fillField(0);
}

function waitGame() {
started = false;
paused = false;
gameOver = false;
}

function gameOverGame() {
started = true;
paused = false;
gameOver = true;
}

function processKeysDown(ev) {
let e = ev.key;

if (!started) {
// "any key" on not started -> to start game
startGame();
return;
}

if (gameOver) {
// "any key" on game over -> to not started
waitGame();
return;
}


if (e === "Escape") {
if (started) {
gameOverGame();
}
return;
}

if (ev.keyCode === 80) {
// P
togglePause();
return;
}

if (ev.keyCode === 83) {
// S
toggleSound();
return;
}

if (e === "Space" || e === ' ') {
// drop
}

if (e === "ArrowDown") {
// move down
}

if (e === "ArrowLeft") {
// move left
}

if (e === "ArrowRight") {
// move right
}

if (e === "ArrowUp") {
// rotate
}

if (e === "Pause") {
togglePause();
}
if (e === "Sound") {
toggleSound();
}
}

function processKeysUp(ev) {
if (ev.key === "ArrowDown") {
//
}
}


function fillField(num) {
for (let row = 0; row < GameField.length; row++) {
if (!GameField[row]) {
GameField[row] = new Array<number>(10) as Array10;
}
for (let col = 0; col < GameField[row].length; col++) {
// num can be a static value, or if it is negative, random value 0..4
GameField[row][col] =
num >= 0
? num
: Math.random() > 0.7
? Math.floor(Math.random() * 4)
: 0;
}
}
}
</script>

<main>
<div id="main-container">
<div id="scene-container">
<Scene {GameField} />
</div>

<div id="info-container">
<Next figure={nextFigure} />
<div id="score-container">
<h1>{score}</h1>
<h2>level {level}</h2>
</div>
</div>
</div>
<div id="help-container">
<Keys />
</div>
</main>

{#if isBanner()}
<div id="banner">
{#if !started}
<StartBanner />
{/if}
{#if paused}
<PauseBanner currentLevel={level} currentScore={score} />
{/if}
{#if gameOver}
<GameOverBanner finalLevel={level} finalScore={score} />
{/if}
</div>
{/if}

<style>
...
</style>

Very simple and clear. The only thing worth mentioning is the reason I have two keyboard events here, keydown and keyup.

That’s because I have a down arrow action — accelerate the fall of the shape while the key is pressed. So I need to process both key down and key up for that arrow.

At the moment I can –

  • start game by any key (“wait game” screen, initial one),
  • can pause it with “P”, then use “P” again to continue or ESC to quit,
  • can interrupt the game (go to “Game Over”) with ESC if it is running,
  • can return from “Game Over” screen to start screen again.

It would be nice to have a confirmation dialog to exit the game, but let’s skip that.

Going to implement game process. What is the game process at all? Well, very few games have a simpler one.

Alco… sorry, algorithm

Now it’s obvious that I need to start by creating a new figure and placing it on the field.

First tricky question, what are the figures and how to get a random one? It seems simple because I’ve already declared them –

// src/figures/figures.ts
import * as THREE from "three";

export const Figures: Record<string, Array<string>> = {
'S': [
' ##',
'## '
],
'Z': [
'##',
' ##'
],
'L': [
'#',
'#',
'##'
],
'J': [
' #',
' #',
'##'
],
'O': [
'##',
'##'
],
'T': [
' #',
'###'
],
'I': [
'#',
'#',
'#',
'#',
],
};

export type TFigureType = keyof typeof Figures;

export type TFigure = {
type: TFigureType
width: number
height: number
object: THREE.Object3D
}

So, they are SZLJTOI . Ok. But I want to add some fun for the players:

const standardFigs = 'SZILTOJ'.split('').filter(s => s !== ' ');
const complexFigs = 'SSS ZZZ II LLL TTT OO JJJ'.split('').filter(s => s !== ' ');

function getRandomFigure(complex = false) {
return complex
? complexFigs[Math.floor(Math.random() * complexFigs.length)]
: standardFigs[Math.floor(Math.random() * standardFigs.length)];
}

You see? For not complex generation, I have a more or less regular distribution. But for the complex, the probability of I is somewhat less. Here I have enough space to make life interesting.

I am ready to start the turn:

  // App.svelte
...
function startGame() {
...
startNewTurn();
}

...
function startNewTurn() {
if (!nextFigure) {
// generate first figure
nextFigure = getRandomFigure(level > 6);
}

addFigureToField(GameField, nextFigure); // <-- generate new figure
nextFigure = getRandomFigure(level > 6);
}

function addFigureToField(GameField: TGameField, type: string) {
const symbol = Figures[type];
if (!symbol) {
throw new Error(`Unknown figure "${type}"!`);
}

const maxHeight = symbol.length;
let maxWidth = symbol.reduce((prev, current) => {
return Math.max(prev, current.length);
}, 0);

const x = Math.floor(5 - maxWidth / 2);
const y = 19 + maxHeight;

for (let line = 0; line < maxHeight; line++) {
let matrix = symbol[line].split("");
for (let cell = 0; cell < matrix.length; cell++) {
if (matrix[cell] !== " ") {
GameField[y - line][x + cell] = 1;
}
}
}
}

What a miracle! If I start and restart the game, I see a new random piece added to the box:

But nothing happens. Maybe it’s because I haven’t added the tick handling code yet?.. All right, I’m going to create tickManager — some subsystem which will do tick-tack with desired speed.

// src/tick-manager.ts

export class TickManager {
private tasks = [];
private intervalHandler;
private phase = 1;
private paused = false;

constructor(private tickDuration) {
//
}

public immediateRestart() {
this.stop();
this.run();
this.phase = 2;
this.process();

this.phase = 1;
this.process();
}

public updateTickDuration = (newDuration) => {
this.tickDuration = newDuration;

this.stop();
this.run();
}

public isPause = () => this.paused;

public setPause(state: boolean) {
this.paused = state;
}

public addTask = (task: () => void, phase) => {
this.tasks.push({ task, phase });
}

public run = () => {
if (!this.intervalHandler) {
this.intervalHandler = setInterval(this.process, this.tickDuration);
}
}

public stop = () => {
clearInterval(this.intervalHandler);
this.intervalHandler = null;
}

public dispose = () => {
this.stop();
this.tasks.length = 0;
}

public clearTasks = () => {
this.tasks.length = 0;
}

public check = () => {
if (this.tasks.length === 0) {
this.stop();
}
this.run();
}

private process = () => {
if (this.paused) {
return;
}

this.tasks.forEach(task => {
if (task.phase === this.phase) {
task.task();
}
});

if (this.phase === 1) {
this.phase = 2;
} else {
this.phase = 1;
}
}
}

Very simple, setInterval — based dispatcher which will run the task passed every single tick… but wait, what is the phase here and why?

Well, interesting question. What do we do each time on a deeper level? Suppose we already have a figure on the field?

  1. Scan the Game Field. For each falling cell (value 1) let’s try to move it down.
  2. It cannot be moved down if it is on the bottom line, or if it has a solid cell at the bottom (value 2), or if any other falling cell cannot move — if any cell of the falling piece encounters an obstacle, the whole piece stops and must be converted from falling (1) to solid (2).
  3. Scan the field again from bottom to top. If there a filled line (all cells value are 2), remove that line and shift all the solid (2) cells above one line down.
  4. Repeat.

Nice, but there’s the trap. Guess where?

Yes, between 2 and 3. Watch my hands: drop everything down… stop everything that needs to be stopped… remove all full lines… render it… wait a bit and go again.

That is, we rendered two frames: before the fall and after everything was processed and deleted. Full lines will simply instantly disappear at the start of the turn. It’s okay, but it seems, um, sloppy.

I would like to have two-phased turns. The first sub-step drops everything one line, renders, then the second sub-step removes the lines, renders. And, by the way, this is the reason why I have four states, not three:

  • 0 — empty cell,
  • 1 — cell of falling figure (dynamic cell),
  • 2 — a solid cell (static),
  • 3 — a solid cell to be deleted.

Huh. Let’s use the tick manager now.

// App.svelte
...
const durations = [400, 300, 220, 160, 120, 80, 60];
let tickDuration = getTickDuration(level);

const tickManager = new TickManager(tickDuration);

...

function getTickDuration(gameLevel) {
return durations[gameLevel - 1] || 50;
}

function setTickDuration(duration?) {
tickDuration = duration && duration > 0 ? duration : getTickDuration(level);
tickManager.updateTickDuration(tickDuration);
}

I’ve added tickDuration — which is practically the duration of each sub-step, which is half the duration of each turn.

Also I added speed matrix (durations) to calculate duration based on current level. Yes, yes, this is a non-linear function, the game will become more difficult with each level, and here you can use all your imagination along with “random” figures. Do you see how much dirty tricks even such an honest and uncomplicated game can have? One can only guess how many there are in CS:GO…

Good. Now — to use this. First I need to add a “force render” to Scene.svelte because of, well, Javascript. What I mean is that on each turn I will change the playing field, moving numbers from cell to cell… but the playing field, the array, will remain the same.

Svelte will not able to detect changes, so it won’t redraw. Ok, I will add some extra parameter:

// components/Scene.svelte

export let GameField: TGameField;
export let tick; // <-- add this...
...
$: if (GameField && Frame && tick) { // <-- and 'tick' here
drawField();
Frame.renderer.render(Frame.scene, Frame.camera);
}

In App.svelte:

// App.svelte
const tickManager = new TickManager(tickDuration);
let tick = 0;

...

<div id="scene-container">
<Scene {GameField} {tick}/>
</div>

And a logic to make turns:

  function startGame() {
if (started || gameOver) {
return;
}
started = true;
paused = false;
gameOver = false;
level = 1;
score = 0;
fillField(0);

startNewTurn(); // <-- put new figure
linesRemovedOnLevel = 0; // how many lines removed

setTickDuration(); // set standard duration of turn

tickManager.addTask(processTick, 1); // first - process + redraw

tickManager.addTask(() => {
const removed = removeFilledLines(GameField);

if (removed > 0) {
score += removed * removed * level;
linesRemovedOnLevel += removed;
}

if (linesRemovedOnLevel >= 10) {
levelUp();
}
}, 2); // second - remove lines and add score

tickManager.run(); // <-- start the main loop one here
}

function processTick() {
if (paused) {
return;
}

const result = fallDown(GameField);

if (result.finished) {
// no falling figure anymore

if (result.stopRow <= 19) {
startNewTurn(); // but there is a space left
} else {
gameOverGame(); // no way, nothing is falling, nothing can fall
}
}
tick++; // <-- to force redraw
}

function levelUp() {
level++;
linesRemovedOnLevel = 0;
setTickDuration(); // <-- increase speed because it based on level
}

Here I’m adding two-phased turn processing:

// first, fall down...
tickManager.addTask(processTick, 1);

// second - remove lines and add score
tickManager.addTask(() => {
const removed = removeFilledLines(GameField);
...
}, 2);

This function, on the second sub-step,

tickManager.addTask(
() => {
const removed = removeFilledLines(GameField);

if (removed > 0) {
score += removed * removed * level;
linesRemovedOnLevel += removed;
}

if (linesRemovedOnLevel >= 10) {
levelUp();
}
},
2); // second - remove lines and add score

is responsible of game progress. Function removeFilledLines() returns the number of lines that were completed, and every 10 lines the sub-step increases level, reduce the duration of the delay (speed up the game).

But the logic behind fallDown() and removeFilledLines()? Well, it is in dedicated file, game-utils.ts :

// src/game-utils.ts
function traverseBottomTop(field, callback: (row, col) => void) {
for (let row = 0; row < field.length; row++) {
for (let col = 0; col < field[row].length; col++) {
callback(row, col);
}
}
}

function makeAllSolids(field) {
traverseBottomTop(field, (row, col) => {
if (field[row][col] === 1) {
field[row][col] = 2;
}
});
}

export function fallDown(field) {
let finished = false;
let stopRow = -1;
let hasToRemove = false;

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

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

if (row === 0) {
finished = true;
stopRow = row;
makeAllSolids(field);
return;
}

const blockBelow = row > 0 ? field[row - 1][col] : 0;
if (block === 1 && (blockBelow && blockBelow !== 1)) {
finished = true;
stopRow = row;
makeAllSolids(field);
}
});

if (!finished) {
traverseBottomTop(field, (row, col) => {
const block = field[row][col];

if (block !== 1) {
// empty cell or solid block
return;
}
// move it down!
field[row - 1][col] = 1;
field[row][col] = 0;
});
}

for (let rowIdx = 0; rowIdx < 20; rowIdx++) {
const line = field[rowIdx];
const filled = line.filter(cell => cell === 2).length === line.length;

if (filled) {
for (let x = 0; x < field[rowIdx].length; x++) {
field[rowIdx][x] = 3;
}
}
}

return {
finished,
stopRow,
hasToRemove
};
}

export function removeFilledLines(field) {
let count = 0;
for (let rowIdx = 0; rowIdx < 20; rowIdx++) {
const line = field[rowIdx];
const filled = line.filter(cell => cell === 3).length === line.length;

if (filled) {
for (let x = 0; x < field[rowIdx].length; x++) {
field[rowIdx][x] = 0;
}
count++;

// move all the rows above down 1 cell
for (let i = rowIdx + 1; i < 20; i++) {
for (let j = 0; j < line.length; j++) {
if (field[i][j] !== 1) {
field[i - 1][j] = field[i][j];
field[i][j] = 0;
}
}
}
rowIdx--; // step backward
}
}

return count;
}

Let’s run and see… it works! The figures fall, going solid, the game ends when there is nowhere to fall.

Wow, what a long article. The part devoted to moving-rotation, I will single out separately.

Just about the “drop”. When user press “Space” I want to drop the figure immediate. The most simple way here is making the turn duration veeeery small till the end of turn. Ok, let’s do that:

// App.svelte
let isDropDown = false
...
function startNewTurn() {
...
if (isDropDown) {
// end drop down
isDropDown = false;
setTickDuration();
}
}
...
function processKeysDown(ev) {
...
if (e === "Space" || e === " ") {
// drop
if (!started || paused) {
return;
}
isDropDown = true;
setTickDuration(5);
score += 1;
return;
}
if (e === "ArrowDown") {
// move down
setTickDuration(20);
processTick();
tickManager.immediateRestart();
}
...
}

...
function processKeysUp(ev) {
if (ev.key === "ArrowDown") {
setTickDuration();
processTick();
}
}

Here, I’m setting the duration to 5 when spacebar pressed, and changing it back at the end of the turn. Also I’m adding 1 more point to score to encourage courage, I think this is pretty fair.

For Arrow Down I just set short duration (20) and return it back when key is released.

The result of the seventh article: now the game is play and even some keys work. Just need to add the controls… I think there are only one or two parts left: processing of three keys, sound effects and overall redesign.

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

To be continued…

--

--

Oleksii Koshkin

AWS certified Solution Architect at GlobalLogic/Hitachi