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

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

Wooo-hooo! I have a Scene, Next Shape, Keyboard Help, Layout and Playfield! Now I’m ready to start making the game itself!

Well, no. Why?

Because even before the gameplay, I need to create, uh, the life cycle of the game. I don’t know what professional game devs call it, but that’s how the game starts, runs, and dies. And whatever else. Damn, seems I need to do something to prepare, again…

Well, for my game it looks like that:

Game design. Just listen to how it sounds!

First, the user waits, seeing something like an advertisement, or wise advice, or a weather forecast, whatever. Game is not started.

The user then plays for a while. Here we see that there is no way for the user to win — the “game over” is always the same. Game over. Game over never changes. That’s why such charts must be kept top secret — the user must never lose hope of winning.

And here there is also a “pause” state from which the user can return to the game or go straight to the end of the game, just so as not to waste time.

Do you understand? I have everything prepared for the green rectangle, and nothing for the other three. Crap.

While it’s already clear how to implement the “play” stage, at least from a UI/IX perspective, I need to decide how to design the other states.

I’m almost out of budget due to expensive casting so I think I’ll outsource it… no, sorry, I mean, my bottle of inspiring rum is nearly empty… no sorry, of course I want to keep things simple because this is a tutorial after all! Yes. Exactly.

So there will be banners. A banner is what I mean by a full-screen, uh, screen, which is separate from the game and displays relevant information:

The banners

I will start with App.svelte

// App.svelte
...
let started = false;
let paused = false;
let gameOver = false;

let level = 1;
let score = 0;

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

For such simple state transitions, I don’t need some complex type to describe the state and manage it. Just three simple boolean variables: started , paused , gameOver and a pair for score and level because I have them on the blueprints. Blueprints. Awesome. Like of the adults.

Then, markup:

<main>
...
</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>
...
#banner {
position: fixed;
z-index: 1;

top: 0;
left: 0;
width: 100vw;
height: 100vh;
}

I won’t include all the banners code here because they are very similar and very simple. Just one, banners/GameOver.banner

<script>
// banners/GameOver.svelte
export let finalScore = 0;
export let finalLevel = 0;
</script>

<div id="gameover-banner">
<h1>GAME OVER</h1>
<h2>Final score: {finalScore} on level {finalLevel}</h2>
<p>press any key to continue</p>
</div>

<style>
#gameover-banner {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-flow: column nowrap;
align-items: center;
justify-content: center;
background: linear-gradient(to bottom right, #FFC900, #FF8500);
color: #63212F
}
h1,
h2 {
margin: 0;
font-weight: normal;
}
</style>

As a result, I have all three:

That’s all? Well yes. Ready to develop gameplay… but I would add a bonus here, because otherwise this part will be too short.

I want to add a rotating cube to each banner. So, start a new component, Cube.svelte (I need to save money, remember? And naming is one of the most difficult problems in software development)

So, I just returned to Stage-1 and copied the content of Scene.svelte — I have a rotating cube there:

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

let Frame: TThreeFrame;
let canvas;
let cube;
let animationReq;

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

onDestroy(() => {
cancelAnimationFrame(animationReq);

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);

cube = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshStandardMaterial({ color: 0xaaaaaa })
);
cube.castShadow = true;
cube.receiveShadow = true;

cube.rotation.x = 1;
cube.rotation.y = -2;
cube.rotation.z = 3;

Frame.scene.add(cube);

const axesHelper = new THREE.AxesHelper(1);
Frame.scene.add(axesHelper);

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);
}

function animate() {
cube.rotation.x += 0.02;
cube.rotation.y += 0.02;
cube.rotation.z -= 0.05;

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

requestAnimationFrame(animate);
}
</script>

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

<style>
#cube {
width: 200px;
height: 200px;
}
</style>

Minor changes in Banners:

<script>
import Cube from "./Cube.svelte"; // <-- add import
</script>

<div id="start-banner">
<h1>TETRIS</h1>
<h2>Welcome to Tetris WebGL Game</h2>
<Cube/> <!-- add Cube -->
<p>press any key to start</p>
</div>

<style>
#start-banner {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-flow: column nowrap;
align-items: center;
justify-content: center;
background: linear-gradient(to bottom right, #00bcff, #007cff);
}
h1,
h2 {
margin: 0;
font-weight: normal;
}
</style>

Voilá:

It’s still too simple. Okay, I remove the background, axes and make the cube a little prettier:

function initScene() {
Frame = {
...
renderer: new THREE.WebGLRenderer({ alpha: true, antialias: true }),
...
};

...
cube = new THREE.Group();

const innerCube = new THREE.Mesh(
new THREE.BoxGeometry(0.7, 0.7, 0.7),
new THREE.MeshStandardMaterial({ color: 0xFFA600 })
);
innerCube.castShadow = true;
innerCube.receiveShadow = true;

const outerCube = new THREE.Mesh(
new THREE.BoxGeometry(1, 1, 1),
new THREE.MeshStandardMaterial({
color: 0xa2de96,
transparent: true,
opacity: 0.5,
})
);
outerCube.castShadow = true;
outerCube.receiveShadow = true;

cube.add(innerCube);
cube.add(outerCube);

...
}

Much better, a cube inside a translucent cube:

I’d stop there… but I am feeling, um… sensei-ish? Let’s improve this animation. Let’s make it shine.

A bit of theory.

requestAnimationFrame is a good place to draw a new frame. The browser will try to call this callback to guarantee a 60fps animation if frame rendering is fast enough.

However, there are no free lunches. If the OS, especially MacOS and especially Safari, decides that the user needs to conserve battery power, the frame rate will drop, sometimes drastically. When the user leaves the page — I can’t imagine why, but they say it happens — the frame rate will be frozen to zero speed.

It seems that everything is spinning smoothly and quickly, then you disconnect the power cable and instead beauty cinematic quality blockbuster you see jerks. It sucks. Even knowing it’s Javascript, it sucks.

What can I do with it? Okay, the very first idea is to enable the performance profile:

renderer: new THREE.WebGLRenderer({
alpha: true,
antialias: true,
powerPreference: 'high-performance' // <-- this one
}),

But it won’t work because Safari doesn’t give a damn. And it is not recommended, anyway. Crap.

But why it jerks? What the reason?

That’s why –

function animate() {
cube.rotation.x += 0.02; // <--
cube.rotation.y += 0.02; // <--
cube.rotation.z -= 0.05; // <--

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

requestAnimationFrame(animate);
}

Everything runs smoothly until the browser starts dropping frames:

Dropping frames + frame-based animation

If I change the angle every tick and the ticks are irregular, my animation will be crappy. At worst, it will be unpredictable and irregularly crappy.

After a bit of thinking, I decided to invent a time-based animation. What does it mean? This means that I will calculate the current angle based on the current timestamp, not just increasing the value on each tick.

In other words, if my animation takes 2s and I want to rotate an object a full circle, 360 degrees, or 2xPI radians, at the 1s mark I need to draw the object with a half rotation. If the tick is not exactly 1s, it’s okay, it will happen somewhere in 1.1s and then my angle will be PI + 10%.

Disclaimer: I know about ThreeJS animations. I want to have my own engine, I warned that I’m going reinvent a lot of wheels. All the wheels.

Anyway, this service can be used for any time-based animations, not only ThreeJS-based. And if you add a “time curve” parameter like “ease”, “ease-in”, etc., it will be quite flexible. It also allows you to run the animation in parallel, sequentially, whatever you like. So this wheel is pretty useful.

Well I have created special service for that:

// src/banners/animation-manager.ts
...
export class AnimationManager {
animations: Array<TAnimation> = [];

public add(params: TAnimationParams) {

const startTime = Date.now() + (params.delay ? params.delay : 0);

this.animations.push({
startTime,
endTime: startTime + params.duration,
currentPercentage: 0,
duration: params.duration,
repeatCount: params.repeatCount,
delayBetween: params.delayBetween,

animationFn: params.animationFn,
finishFn: params.finishFn,
onCycleFn: params.onCycleFn,
prepareFn: params.prepareFn,

markToDelete: false,
started: false,
cycle: 1
});

params.prepareFn && params.prepareFn();
}

public play() {
const now = Date.now();

let needCleanup = false;
// play cycle
this.animations.forEach(animation => {
if (animation.startTime > now) {
return; // delay yet
}
if (!animation.started) {
animation.started = true;
animation.prepareFn && animation.prepareFn();
}

const fullDuration = animation.endTime - animation.startTime;
const currentDuration = now - animation.startTime;
const percentage = (currentDuration / fullDuration) * 100;

if (percentage < 100) {
animation.animationFn(percentage, animation.cycle);
return;
}

animation.onCycleFn && animation.onCycleFn(animation.cycle);
animation.animationFn(100, animation.cycle);

if (!animation.repeatCount || animation.cycle === animation.repeatCount) {
animation.markToDelete = true;
needCleanup = true;
return;
}
// new cycle
animation.cycle++;

animation.startTime = Date.now() + (animation.delayBetween ? animation.delayBetween : 0);
animation.endTime = animation.startTime + animation.duration;
});

if (needCleanup) {
this.cleanup();
}
}

private cleanup() {
// remove finished animations
for (let i = this.animations.length - 1; i >= 0; i--) {
if (this.animations[i].markToDelete) {
this.animations[i].finishFn && this.animations[i].finishFn(); // cleanup if needed
this.animations.splice(i, 1);
}
}
}

public dispose() {
this.animations.length = 0;
}
}

Let’s see what it does. This is pretty powerful version, it supports:

delay?: number
delayBetween?: number
duration: number
repeatCount?: number

animationFn: TAnimationFn // main animation cycle

prepareFn?: () => void // before animation starts
onCycleFn?: (cycle: number) => void // on every cycle finish/new start
finishFn?: () => void // before remove

User can specify the delay to start animation, its duration (one of two mandatory parameters), and how it will repeat.

The minimal animation is just a duration and animationFn, function to animate. But here could be repeatCount — how many times to repeat the animation– delayBetween each cycle, an onCycleFn to call on each cycle, preparation- and finishing functions…

Better to show how to use it in the wild.

First, I need to describe the animation. Let’s say, it will be full-x-rotation:

// banners/cube-rotate-x-animation.ts
import type { TAnimations } from "./animation-manager";

export class CubeRotateXAnimations implements TAnimations {
constructor(private cube) {
//
}

public getAnimation = () => {
return {
duration: 2000,
animationFn: this.doAnimation,
finishFn: this.endAnimation,
repeatCount: Infinity
};
}

private doAnimation = (percentage) => {
const distance = Math.PI * 2; // <-- full rotate
const currentRotation = (distance * percentage) / 100;

this.cube.rotation.x = currentRotation;
}

private endAnimation = () => {
this.cube.rotation.x = 0;
}
}

As you can see, an animation must implement getAnimation object that describes the configuration. This one includes–

  1. duration will be 2 seconds.
  2. animationFn will calculate angle of rotation and assign it to x axis.
  3. finishFn (not necessary one) will return the rotation to initial state.
  4. repeatCount is Infinity . This means that the animation will repeat over and over again, an infinite loop, and so finishFn is not really needed. But let it be.

How works animation function? Well, AnimationManager 's play() function will call all the animations with their percentage. How it knows? When animation is added to AnimationManager, it calculates startTime (the time when added) and endTime (startTime + duration).

When play() is called, it gets the current time and calculates the percent complete for each of the animations. If the animation has a repeatCount, the AnimationManager restarts startTime after the loop.

All you need to do is call play() sometimes when you can afford it, such as in requestAnimationFrame(). The code will take care to make things smooth, more or less.

Back to code. In Cube.svelte I making some changes:

<script lang="ts">
// banners/Cube.svelte
...
// new imports
import { AnimationManager } from "./animation-manager";
import { CubeRotateXAnimations } from "./cube-rotate-x-animation";
import { CubeRotateYAnimations } from "./cube-rotate-y-animation";
import { CubeRotateZAnimations } from "./cube-rotate-z-animation";
import { CubeScaleAnimations } from "./cube-scale-animation";
import { CubeColorAnimations } from "./cube-color-animation";
// ---

let Frame: TThreeFrame;
...
const animationManager = new AnimationManager(); // new Animation Manager

onMount(() => {
initScene();

// add these parallel animations:
animationManager.add(new CubeRotateXAnimations(cube).getAnimation());
animationManager.add(new CubeRotateYAnimations(cube).getAnimation());
animationManager.add(new CubeRotateZAnimations(cube).getAnimation());
animationManager.add(new CubeScaleAnimations(cube).getAnimation());
animationManager.add(new CubeColorAnimations(cube).getAnimation());
// ---

animate();
});

onDestroy(() => {
cancelAnimationFrame(animationReq);

if (!Frame) {
return;
}

animationManager.dispose(); // <-- to cleanup
...
});

...
function animate() {
animationManager.play(); // <-- add this to play

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

requestAnimationFrame(animate);
}
</script>

Wow, I added FIVE animations to the cube: rotation on each axis (at different speeds), scaling up and down, and color animation of the main cube. Got carried away a little.

By the way, this is the color animation:

// src/banners/cube-color-animation.ts
import type { TAnimations } from "./animation-manager";

export class CubeColorAnimations implements TAnimations {
private material;

constructor(private cube) {
this.material = this.cube.children[0].material;
// the material of the first object in the cube group
}

public getAnimation = () => {
return {
duration: 10000,
animationFn: this.doAnimation,
repeatCount: Infinity
};
}

private doAnimation = (percentage) => {
const distance = Math.PI * 2;
const currentScale = Math.abs(Math.sin((distance * percentage) / 100));

const currentColor = Math.floor(255 * currentScale);

this.material.color.set((256 - currentColor) + currentColor * 256 + currentColor * 256 * 256);
}
}

And finally:

So, the result of the sixth article: game states, Banners components for them, and time-based animation engine. Not bad.

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

To be continued…

--

--

Oleksii Koshkin

AWS certified Solution Architect at GlobalLogic/Hitachi