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

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

At the moment I have the Next Figure and Keyboard Help 3D components. I decided to show everything else (banners, score, level) as a simple HTML div, so I seem to have everything to get started with gameplay?

No. I need a Scene first. Remember the very first component I created? Only with a cube? Yes, that one. Now I need to make this component able to display the playfield.

All game logic will be in App.svelte, but the visual representation is the task of the Scene component.

This part will be especially long; don’t worry, it’s simple. Just long.

Think a little about the playing field itself. This is a 20 row, 10 column field… or Array for Javascript. Also I would add an extra 4 lines for the next shape (starting position).

Nice. How I will encode cell state? As simple as–

  • 0 — empty cell,
  • 1 — cell of falling figure (dynamic cell),
  • 2 — a solid cell (static),
  • 3 — a solid cell to be deleted. In other words, a filled line. Why I need to have separate status for it? Because I want to display the removal process and also make the gameplay code a bit more sophisticated.

What should I do first… no, not creating a component. Not even creating a playfield array. First I need to provide a valid container and layout.

Well, let’s return to App.svelte

Here is a layout I want to have:

Corresponding App.svelte :

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

const figures = "SZLJITO".split("");
let figure = "";

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

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

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

<style>
#main-container {
display: flex;
flex-flow: row nowrap;
align-items: center;
align-content: center;
justify-content: center;
position: absolute;
top: 0;
left: 0;
width: 100%;
bottom: 100px;
background-color: rebeccapurple;
}
#scene-container {
background-color: bisque;
width: auto;
aspect-ratio: 10 / 24;
height: 100%;
align-self: center;
position: relative;
margin-left: 60px;
}
#info-container {
height: 100%;
width: 120px;
text-align: center;
background-color: cadetblue;
}
#info-container h1,
#info-container h2 {
font-weight: normal;
margin: 0;
padding: 0;
}
#help-container {
display: flex;
justify-content: center;
position: absolute;
left: 0;
bottom: 0;
width: 100%;
background-color: aquamarine;
}
</style>

And browser shows:

Pretty straightforward one. The only interesting thing here — calculation of scene dimensions:

#scene-container {
width: auto;
aspect-ratio: 10 / 24; /* let's CSS do all the work for us! */
height: 100%;
align-self: center;
position: relative;
margin-left: 60px; /* move a bit to right, to be more centered */
}

Quick check on Can I Use

Nice, 91.67%. I don’t even want to worry about these 8% of marginals, they can always buy a more modern browser. Anyway, any game that respects itself has decent system requirements, and let they be thankful that I don’t demand a GeForce Ti 5090.

Time to change the Scene.svelte ? Not yet. First, here, in App.svelte I have to create a game field.

But even before first I need to declare some types:

// components/types.ts
type GrowToSize<T, N extends number, A extends T[]> = A['length'] extends N ? A : GrowToSize<T, N, [...A, T]>;
type FixedArray<T, N extends number> = GrowToSize<T, N, []>;
type CellType = 0 | 1 | 2 | 3;

type Tuple<
T,
N extends number,
R extends T[] = [],
> = R['length'] extends N ? R : Tuple<T, N, [T, ...R]>;

export type Array10 = Tuple<CellType, 10>;

export type TGameField = FixedArray<Array10, 24>;
// App.svelte
import type { Array10, TGameField } from "./components/types";

...

// create an empty game field
const GameField:TGameField = new Array<Array10>(24) as TGameField;
// and fill it with random values (num === -1)
fillField(-1);

printGame(GameField);

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..3
GameField[row][col] =
num >= 0
? num
: Math.random() > 0.7
? Math.floor(Math.random() * 4)
: 0;
}
}
}

function printGame(GameField:TGameField) {
for (let i = GameField.length - 1; i >= 0; i--) {
let s = i.toString().padStart(2, " ") + " ";

for (let j = 0; j < GameField[i].length; j++) {
if (GameField[i][j] === 0) {
s += ".";
}
if (GameField[i][j] === 1) {
s += "F";
}
if (GameField[i][j] === 2) {
s += "S";
}
if (GameField[i][j] === 3) {
s += "X";
}
}
console.log(s);
}
}

...

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

Here you can see all the most useful techniques for game development: global variables, function with side effects... all for the sake of performance, of course!

In console I see correct output. So, the best moment to start render the data, yeah.

I removed all the garbage, and this is “empty” Scene.svelte

<script lang="ts">
import { onMount, onDestroy } from "svelte";
import * as THREE from "three";
import type { TGameField, TThreeFrame } from "./types";

export let GameField: TGameField;

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(0, 0, 10);
Frame.camera.updateProjectionMatrix();

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

const pointLight1 = new THREE.PointLight(0x888888ff, 0.8, 10);
pointLight1.position.set(-4, -4, -4);
pointLight1.castShadow = true;

const pointLight2 = new THREE.PointLight(0xff0088, 0.8, 20);
pointLight2.position.set(4, -3, -6);
pointLight2.castShadow = true;

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="scene" bind:this={canvas} />

<style>
#scene {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
</style>

Just a black rectangle. I want to have this one:

Well, there are walls and points. Let’s start with them.

This is special file dedicated to create scene objects:

// components/scene-objects.ts

import * as THREE from "three";

const wallGeometry = new THREE.BoxGeometry(0.9, 0.9, 1.5);
const wallCubeMaterial = new THREE.MeshStandardMaterial({color: 0x0095DD});
const wallCube = new THREE.Mesh(wallGeometry, wallCubeMaterial);

function createWallBrick(x, y, z) {
const cube = wallCube.clone();
cube.position.set(x, y, z);
cube.castShadow = true;
cube.receiveShadow = true;

return cube;
}

export function createWalls() {
const wallGroup = new THREE.Group();
// left
const verticalWall = new THREE.Group();
for (let i = 0; i < 20; i++) {
verticalWall.add(createWallBrick(0, i, 0));
}
// right
const verticalWall2 = verticalWall.clone();
verticalWall.position.setX(-5 - .5);
verticalWall.position.setY(-9 - .5);

verticalWall2.position.setX(5 + .5);
verticalWall2.position.setY(-9 - .5);

wallGroup.add(verticalWall);
wallGroup.add(verticalWall2);

// bottom
const bottomWall = new THREE.Group();
for (let i = 0; i < 12; i++) {
bottomWall.add(createWallBrick(i, 0, 0));
}
bottomWall.position.setX(-6 + .5);
bottomWall.position.setY(-10 - .5);
wallGroup.add(bottomWall);

return wallGroup;
}

const spaceGeometry = new THREE.SphereGeometry(0.03, 4, 4);
const spaceMaterial = new THREE.MeshStandardMaterial({color: 0x9999FF});

export function createSpaceItems() {
const spaceGroup = new THREE.Group();

for (let i = 0; i <= 9; i++) {
for (let j = 0; j <= 19; j++) {
const item = new THREE.Mesh(spaceGeometry, spaceMaterial);
item.position.set(i, j, -1);
spaceGroup.add(item);
}
}
spaceGroup.position.setX(-5 + .5);
spaceGroup.position.setY(-10 + .5);

return spaceGroup;
}

There are two exported functions here, createWalls() and createSpaceItems(). I need just add their calls to initScene() of Scene.svelte :

  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 = 1;

const light = new THREE.DirectionalLight(0xffffff, 2);
light.position.set(3, 40, 2);
light.castShadow = true;
light.rotateX(5);

Frame.scene.add(light);

const plight = new THREE.PointLight(0xddffff, 0.7, 50);
plight.position.set(4.5, 0, 4);
plight.castShadow = true; // default false

Frame.scene.add(plight);

const semiLight = new THREE.HemisphereLight(0x8080a0, 0x222222, 0.64);

Frame.scene.add(semiLight);

const ambientLight = new THREE.AmbientLight(0x505050);

Frame.scene.add(ambientLight);

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

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

canvas.appendChild(Frame.renderer.domElement);

Frame.scene.add(createWalls()); // <-- add walls
Frame.scene.add(createSpaceItems()); // <-- add space dots

adjustPerspectiveCamera(Frame.camera, 0.8); // <-- but what is it???

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

Setting lights, adding walls, adding dots… pretty clear, but what is that adjustPerspectiveCamera() ? Uh, it is very useful function, I’ve got the idea here:

function adjustPerspectiveCamera(camera, offset) {
// https://wejn.org/2020/12/cracking-the-threejs-object-fitting-nut/
offset = offset || 1.5;

const boundingBox = new THREE.Box3(
new THREE.Vector3(-6, -12, -6),
new THREE.Vector3(6, 12, 6)
);
const size = new THREE.Vector3();
boundingBox.getSize(size);
const center = new THREE.Vector3();
boundingBox.getCenter(center);

camera.position.set(0, 0, 100);

const fov = camera.fov * (Math.PI / 180);
const fovh = 2 * Math.atan(Math.tan(fov / 2) * camera.aspect);
let dx = size.z / 2 + Math.abs(size.x / 2 / Math.tan(fovh / 2));
let dy = size.z / 2 + Math.abs(size.y / 2 / Math.tan(fov / 2));
let cameraZ = Math.max(dx, dy);

if (offset !== undefined && offset !== 0) cameraZ *= offset;

camera.position.set(0, 0, cameraZ);

const minZ = boundingBox.min.z;
const cameraToFarEdge = minZ < 0 ? -minZ + cameraZ : cameraZ - minZ;

camera.far = cameraToFarEdge * 3;

camera.lookAt(center);
camera.updateProjectionMatrix();
}

This allows you to automatically adjust, you’ll be surprised, the perspective camera to display the right area for guaranteed visibility. A very, very useful feature, especially if the viewport may be different: if I decide to make an adaptive design, then this is a must have.

I’d add the call of this function in resize callback, like

// on resize callback/event
...
// actual width, height calculation, like
// const width = canvaw.offsetWidth;
...
Frame.renderer.setSize(width, height);
(Frame.camera as THREE.PerspectiveCamera).aspect = width / height;

adjustPerspectiveCamera(Frame.camera as THREE.PerspectiveCamera, 0.8);
Frame.renderer.render(Frame.scene, Frame.camera);

Huh. The time to render, in the end!

Let’s add the render function:

// components/Scene.svelte
// at top
export let GameField: TGameField;
let renderField; // <-- 3D representation of GameField: group of cubes
...

onMount(() => {
initScene();
drawField(); // <-- add rendering the field
Frame.renderer.render(Frame.scene, Frame.camera);
});
...
const fallingMaterial = new THREE.MeshStandardMaterial({
color: 0xffa600,
// transparent: true,
// opacity: 0.5,
// side: THREE.DoubleSide
});

const deletingMaterial = new THREE.MeshStandardMaterial({
color: 0xff0000,
transparent: true,
opacity: 0.8,
side: THREE.DoubleSide,
});

function drawField() {
if (!Frame) {
return;
}

if (renderField) {
// I group everything related to game field in renderField group,
// and re-create its content and the group itself every time.
const objectsToRemove = [];

renderField.traverse((node) => {
if (node instanceof THREE.Mesh) {
objectsToRemove.push(node);
}
});

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

Frame.scene.remove(renderField);
renderField = null;
}

renderField = new THREE.Group();

for (let row = 0; row < 24; row++) {
// vertical
for (let col = 0; col < GameField[row].length; col++) {
// horizontal
if (GameField[row][col]) { // if not 0...
let cube;

if (GameField[row][col] === 3) {
cube = createCube(deletingMaterial);
}

if (GameField[row][col] === 1) {
if (!cube) {
cube = createCube(fallingMaterial);
}
}

if (!cube) {
cube = createCube(); // 2, solid
}

cube.position.x = col;
cube.position.y = row;

renderField.add(cube);
}
}
}
// shift all the scene to match coordinates...
renderField.position.x = -5 + 0.5;
renderField.position.y = -10 + 0.5;
renderField.position.z = 0;

Frame.scene.add(renderField);
}

…and it works:

Render and the game field (at right)

The code is simple, like any other code here, but correctly displays a random playing field with all three types of dice: falling, solid, going-to-be-deleted.

So, are you all set? Not yet. Let’s check how it will be updated (spoiler: no way, yet).

I added super-simple dynamic code to App.svelte which generates a random playing field every single second:

// App.svelte

setInterval(() => {
fillField(-1);
printGame(GameField);
}, 1000);

And I can see in the console that the field is changing… but not being redrawn.

Ah, for sure I need to add reactivity to Scene.svelte! Ok:

// components/Scene.svelte
...
let Frame: TThreeFrame;
let canvas;

$: if (GameField && Frame) {
drawField();
Frame.renderer.render(Frame.scene, Frame.camera);
}

Just redraw the current field on every update. Works like clockwork! Almost as Apple Watch Ultra but better!

It reacts!..

I removed backgrounds from styles and replaced

renderer: new THREE.WebGLRenderer({ alpha: false, antialias: true })

with

renderer: new THREE.WebGLRenderer({ alpha: true, antialias: true }) // alpha

everywhere. Also, I see inconsistencies and mismatches between the various parts, but let’s skip that for now.

So, the result of the fifth article: 3D scene component which is able to display the game field state and its changes. I’m getting closer and closer to the gameplay…

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

To be continued…

--

--

Oleksii Koshkin

AWS certified Solution Architect at GlobalLogic/Hitachi