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

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

Now I have a project, a 3D scene and the first 3D object on it. Time to start thinking!

I know all about gameplay. I played Tetris for days and went through it completely several times. Well, kind of. Anyway, I don’t have to think about scenes and casting, I mean objects and subjects.

But what is a game UI? Let’s do some drawing.

Where is 3D here?

So I have three 3D contexts or three 3D components to display on the game page. One main (scene) and two auxiliary ones: the next figure and keyboard help. Of course, I can limit myself to just one, but come on, fun is fun.

Well, let’s skip for now scene, skip keyboard and start with “the next figure”. Let’s start by creating a new component src/components/Next.svelte and put it instead Scene.svelte to App.svelte, just to debug:

<script lang="ts">
// App.svelte
import Next from "./components/Next.svelte";
</script>

<main>
<Next/>
</main>

<style>
/* nothing yet here */
</style>

Here I need to do some calculations. I want to display any of the available shapes and here the dimensions are from 1x4 (‘I’) to 3x2 or 2x3 (‘L’, ‘J’, ‘T’, ‘S’, ‘Z’). Ok, the view filed will be 4x4 to fit anything.

I want to have a visual square of 30x30px for a single cell in order to have a resultant component size of 120x120px.

Ok, let’s use most the code from Scene.svelte — remove animations, add input parameter export let figure = 'S' and cleanup initScene() for now. Just an empty 3D canvas.

<script lang="ts">
// components/Next.svelte

import { onMount, onDestroy } from "svelte";
import * as THREE from "three";
import type { TThreeFrame } from "./types";
import type { TFigureType } from "../figures/figures"; // a bit later

export let figure: TFigureType = "#"; // initial figure to display

let Frame: TThreeFrame;
let canvas;

onMount(() => {
initScene();
});

onDestroy(() => {
if (!Frame) {
return;
}

Frame.renderer.dispose();
Frame.renderer.forceContextLoss();
Frame.renderer.domElement = null;
Frame.renderer = null;
});

function initScene() {
Frame = {
scene: new THREE.Scene(),
renderer: new THREE.WebGLRenderer({ alpha: false, antialias: true }),
camera: new THREE.PerspectiveCamera(
75,
canvas.offsetWidth / canvas.offsetHeight,
0.1,
500
),
};

Frame.camera.zoom = 5;
Frame.camera.position.set(10, 0, 10);
Frame.camera.lookAt(0, 0, 0);
Frame.camera.updateProjectionMatrix();

const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(0, 4, 30);
light.castShadow = true;

const pointLight1 = new THREE.PointLight(0x88aaff, 0.8, 20);
pointLight1.position.set(8, 4, -4);
pointLight1.castShadow = true;

const pointLight2 = new THREE.PointLight(0xffaa88, 0.8, 60);
pointLight2.position.set(4, -3, -6);

Frame.scene.add(light);
Frame.scene.add(pointLight1);
Frame.scene.add(pointLight2);

Frame.renderer.shadowMap.enabled = true;
Frame.renderer.shadowMap.type = THREE.PCFSoftShadowMap;

Frame.renderer.setSize(canvas.offsetWidth, canvas.offsetHeight);

canvas.appendChild(Frame.renderer.domElement);

Frame.renderer.render(Frame.scene, Frame.camera);
}
</script>

<div id="next" bind:this={canvas} />

<style>
#next {
width: 120px;
height: 120px;
}
</style>

See these ‘120px’ in style section? Yes, that’s it.

And in the browser (with axes helper + cube 1x1x1 at 0,0,0 and camera at 0,0,10 — straight look):

Great. Just need to draw something real here.

Let me speak a bit about ‘character generators’ or ‘symbols generators’. Long ago, when computers were big and screens were small, this was a very widely used method for describing how a particular letter should be displayed. It is still used even now for “bitmap fonts”.

The method itself is very simple and straightforward, like many things of that time. No curves, beziers, formulas, hints, ligatures… such lovely days…

Somewhere you just have a matrix of bits (font) and some special chip (generator) converts them into pixels, row by row, column by column, with direct mapping:

I will use the same technique to describe and draw shapes. Create a file src/figures/figures.ts

// figures/figures.ts

import * as THREE from "three";

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

export type TFigureType = keyof typeof Figures;

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

Very, very simple. I don’t even use binary format like 0b001100, just strings… c’mon, that’s Javascript. Space will be a space, non-space will be a filled cell.

If you pay attention, there is one special character: #, which represents a fully filled 4x4 matrix. I’ll be using it to set up the component: with this character, the canvas should be a) fully visible and b) fully populated.

Okay, now I have matrices and I need to create a generator that will build 3D shapes.

I’ve created a file figures/builder.ts which is the symbol generator. The code:

// figures/builder.ts

import * as THREE from "three";
import type { TFigure, TFigureType } from "./figures";
import { Figures } from "./figures";

const FigureCache: Record<keyof typeof Figures, TFigure> = {};

export function createFigure(type: TFigureType): TFigure {
if (!FigureCache[type]) {
renderFigureToCache(type);
}
return FigureCache[type];
}

// greenish cube
const cubeGeometry = new THREE.BoxGeometry(0.9, 0.9, 0.9);
const cubeMaterial = new THREE.MeshStandardMaterial({ color: 0xaaddaa });

function createCube() {
return new THREE.Mesh(cubeGeometry, cubeMaterial);
}

function renderFigureToCache(type: TFigureType) {
const symbol = Figures[type];
if (!symbol) {
return;
}

const group = new THREE.Group();

const SymbolHeight = symbol.length;
let maxWidth = 0;

for (let line = 0; line < SymbolHeight; line++) {
let matrix = symbol[line].split('');
if (matrix.length > maxWidth) {
maxWidth = matrix.length;
}

for (let cell = 0; cell < matrix.length; cell++) {
if (matrix[cell] !== ' ') {
const pixel = createCube();
pixel.position.set(cell, SymbolHeight - line, 0);
pixel.castShadow = true;
pixel.receiveShadow = true;

group.add(pixel);
}
}
}

FigureCache[type] = {
type,
object: group,
width: maxWidth,
height: SymbolHeight
};
}

First interesting thing is FigureCache . The idea is quite simple: in order not to repeat the rendering all the time, I will put the shape in the cache and return it next time.

This is followed by two nested loops that scan the matrix of the requested character and create cubes for non-empty cells.

The next thing is createCube() function. Very simple one and you can replace its logic with a complex version of geometry, materials, etc. Now it just a greenish cube.

const cubeGeometry = new THREE.BoxGeometry(0.9, 0.9, 0.9);

0.9 here — to have a gap between cubes. It must be remembered that the objects in ThreeJS are calculated by the center of the object (to be honest, by the pivot point, but for simplicity we will omit it):

Each side of the cube is extruded from the center to size/2

And in the end I am ready to add a render logic to Next.svelte component.

// components/Next.svelte
...
export let figure: TFigureType = "#";
...
// just after onMount
$: if (Frame && figure) {
placeFigure(figure); // on change figure...
}

...
function cleanup() {
const objectsToRemove = [];
Frame.scene.traverse(function (node) {
if (node instanceof THREE.Mesh) {
objectsToRemove.push(node);
}
});

objectsToRemove.forEach((node) => {
node.parent.remove(node);
});
}

function placeFigure(type) {
cleanup();

const figure = createFigure(type);
const obj = figure.object.clone(); // <-- see comments

Frame.scene.add(obj);

obj.position.y = -0.5 - figure.height / 2;
obj.position.x = 0.5 - figure.width / 2;

Frame.renderer.render(Frame.scene, Frame.camera);
}

Here I’m using Svelte’s reactivity $:... to call placeFigure() on changing the input parameters (export let figure...).

The function will make scene clear — delete all currently existing objects, in other words, all the cubes. Here you can notice an interesting practice — first collect all elements using traverse (a recursive function from ThreeJS), and then remove all collected objects.

The new figure is cloned (feature from ThreeJS) to don’t destroy the cached object on cleanup:

const obj = figure.object.clone();

Then a new figure going to be added to the scene with some position adjustment to make it centered.

obj.position.y = -0.5 - figure.height / 2;
obj.position.x = 0.5 - figure.width / 2;

It works! I moved the camera a little to make the view a bit less boring–

Frame.camera.zoom = 5;
Frame.camera.position.set(10, 0, 10); // <--
Frame.camera.lookAt(0, 0, 0); // <--
Frame.camera.updateProjectionMatrix();

..and I scaled it up a little more than just to fit (5 instead of 4) because for real figures we never have a 4x4 field with cubes in the corners.

Well, the last one — I added some simple code to App.svelte to have a slideshow:

<script lang="ts">
import Next from "./components/Next.svelte";

const figures = "SZLJITO".split(""); // <-- all the figures available
let figure = "";

setInterval(() => {
// set random figure
figure = figures[Math.floor(Math.random() * figures.length)];
}, 500);
</script>

<main>
<Next {figure} />
</main>
Works like a charm. You can even expand the set of drawings and display texts.

So, the result of the third article: 3D component to display the next figure.

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

To be continued…

--

--

Oleksii Koshkin

AWS certified Solution Architect at GlobalLogic/Hitachi