Building 3D 2048 game with Vue, Three.js and TypeScript (part 1)

So, this post is going to be a little different. Namely, instead of being just standard-grade content, it's going to be a nice tutorial and kind-of documentation for my personal project, which is in making at the time of writing this post. I hope I managed to somehow get you interested by now, whether it's by this little description or by the title itself. So, what are we going to build?

If you stumbled upon this article, I guess you already have some knowledge about Vue (JS framework), TypeScript (statically-typed compile-to-JS language) and maybe even Three.js (WebGL library). If you don't, it's recommended that you take a look at their official docs, or search for some other resources, before continuing. I will, however, try to explain all the details as well as I possibly can.

In this post (and a few future ones), we're going to utilize all these listed tools, to create our own implementation of classic 2048 board game... in 3D! We'll use Three.js for 3D stuff, Vue for UI and as an abstraction layer from our 3D scene, and TypeScript for great development experience. With that said, in this exact article, we're going to only set up our project - its basic structure, UI and a simple 3D scene, to have some basic stuff ready for the future development! So, bear with me, as we'll be creating our own 3D game!

Example 2048 game board - taken from wikipedia.org

Setup

We'll first set up our project structure, with all the tools and configs needed. For that, I'll use Vue CLI to bootstrap the project more quickly.

yarn global add @vue/cli

We'll install the Vue CLI globally - the recommended way. In this way, you can later utilize the same CLI for all your upcoming Vue projects. Also, throughout this tutorial, I'm using Yarn instead of NPM as my package-manager-of-choice. If you decide to use Yarn too, just remember to have your PATH variable configured correctly.

To actually set up the project, you'll need to invoke Vue CLI create command with the name of your project (and it's later folder name) as an argument.

vue create 2048

The CLI will guide you through the setup process. Be sure to pick the features manually. In my case, I'm using Babel, TypeScript (together with class components), Vuex, PWA and ESLint, as they'll definitely be useful later. Then, open your favorite code editor and get into the project's folder.

cd ./2048

As a side-note, you might want to know that Vue CLI also provides a nice UI for creating and managing your Vue projects. With it, you can add dependencies, plugins, configure and run your tasks and more! Thus, it could be quite helpful in our development process. You can open it with a simple command:

vue ui

Overview

So, as we've already set up our project, it's time to make a little overview of what's inside. As far as I know, the configuration files shouldn't require any changes, thus, we'll be taking a look at the project files only. But, before that - package.json!

Personally, I run Vue CLI, so that all config files are separate, outside of package.json. In this way, it's much less bloated, leaving us only with some basic data, dependencies list, and a bunch of scripts.

{
// ...
    "scripts": {
        "serve": "vue-cli-service serve",
        "build": "vue-cli-service build",
        "lint": "vue-cli-service lint"
    }
// ...
}

"serve" and "build" tasks are provided by default, whatever the setup you've entered. The first one uses HMR, effectively improving our development workflow, while the second one creates optimized output builds. If you turned on the linter during the setup process, you'll end up with one additional "lint" task, specifically for running the linter itself. I think it's worth noting that even if I'm using TypeScript for this project, I chose ESLint for my linter instead of TSLint. ESLint support for TypeScript is constantly getting better, and with a superior number of features, it's an option to consider. Of course, Vue CLI will be happy with any one of those.

Leaving all config files behind, we go straight to the public folder. Here, the two most important files are index.html and manifest.json. index.html, beyond its basic functionalities, serves as a template file for our Vue builds. Bundled code will be put in there automatically. Also, it's a great place for external fonts and CSS to be loaded if necessary.

<!-- ... -->
<body>
    <noscript>
        <strong>
            <!--JavaScript unavailable message-->
        </strong>
    </noscript>
    <div id="app"></div>
    <!-- built files will be auto injected -->
</body>
<!-- ... -->

manifest.json will only appear in case you configure the PWA option. It's mainly just a standard JSON file, containing some metadata about the given app to make PWA functionality work correctly. Here, things that we might want to configure later include our color settings and icons. Still, it's a pretty easy and straight-forward process.

{
  "name": "2048",
  "short_name": "2048",
  "icons": [
    {
      "src": "./img/icons/android-chrome-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "./img/icons/android-chrome-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "start_url": "./index.html",
  "display": "standalone",
  "background_color": "#000000",
  "theme_color": "#4DBA87"
}

Now, let's go to the most important directory of the whole project - src. Here, all the files we'll be interacting the most with are located. It's important to notice that the use of TypeScript with Vue is still catching up, both in popularity and support, and thus, you might find some things that are new to you. So, please, bear with me for this one.

We start from the file where all the things start - main.ts. Here, in the standard fashion, our Vue instance is created and mounted. Our main <App/> component is imported from App.vue file, which we'll get back to in a second. Then, we also import the registerServiceWorker.ts file, where our PWA service worker is registered, handling the caching of our files in production mode. But, what's more important right now is our Vuex store setup in the store.ts file. Here, we'll store the current state of the game board, user settings and some other metadata, like user's highest score. Everything in order, in one, single place.

import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

export default new Vuex.Store({
  state: {

  },
  mutations: {

  },
  actions: {

  },
});

The whole main.ts file should look somewhat like this:

import Vue from 'vue';
import App from './App.vue';
import store from './store';
import './registerServiceWorker';

Vue.config.productionTip = false;

new Vue({
  store,
  render: h => h(App),
}).$mount('#app');

In the src folder, there are also two sub-directories - one for assets and one for components. It's always good to have some order when it comes to the files structure. The components folder should contain only .vue files, just like our App.vue. But, remember that we're talking about TypeScript here! So, what is going to change within our Vue template files?

Well, first, let's take a look at the general structure of our standard Vue template:

<template>
</template>

<script lang="ts">
</script>

<style>
</style>

The main <template/> tag remains unchanged. Our <script/> tag obtains new lang attribute, to inform the transpiler of the use of TypeScript. Finally, I decided to not go with any CSS preprocessor, so only pure CSS <style/> tag remains.

Now, in the case of Vue templates, you might be accustomed to just exporting an object with some properties and methods, like data or methods. With TypeScript, things are looking a little different...

import { Component, Vue } from 'vue-property-decorator';

@Component
export default class App extends Vue {}

As you can see, instead of an object, the primary value we export is a class extending the base Vue class. What you see above that with a nice-looking @Component syntax is a so-called decorator. It makes external modification and annotation of classes and properties possible. It's currently a stage 2 proposal of ECMAScript, leaving us to speculate if it's going to make it to the standard-grade JavaScript release one day. Of course, they are already implemented in Babel and TypeScript (although mostly in its stage-1-like version), where they gained popularity because of their use-cases in Angular.

The official @Component decorator provided by the vue-class-component package serves exactly the same purpose as demonstrated - to create class-based Vue components, mainly in TypeScript. The vue-property-decorator is the 3rd-party package, although very popular and acknowledged, that implements the @Component decorator, and provides you with 6 more! They allow you to e.g. use props just like they were class properties, instead of specifying them through the @Component decorator. Anyway, we'll most-likely explore most of this stuff down the road.

Also, the class + @Component decorator approach comes with a few benefits of its own! For example, you can declare your data properties, computed properties, and methods, just like they were properties and methods of the given class.

// ...
@Component
export default class App extends Vue {
    myProp: 10;
    
    get myComputedProp() {
        return this.myProp;
    };
    
    myMethod() {
        this.myProp++;
    }
}

Notice that, instead of computed properties, you can simply use native JS getters and setters to achieve the same result, with a much nicer and denser syntax! After all, I think that the class components present a great alternative for what we've already seen. Their only downside right now is small user-base (but growing!) and thus, the general support for this solution. But, I think it's still worth a try.

Vuetify

Our game - the UI and WebGL content itself - should have a unified look. I decided to go with one of my favorite and very popular design system - Material Design (MD). We won't be implementing everything according to the specs - only the general look and feel. From Vue's site, we'll use Vuetify - a great library that gives you access to a lot of MD components on its own. Also, such choice will greatly simply our WebGL scene - we'll use flat lighting and add some low-poly look to our tiles, so everything will have a unified feeling.

For now, we'll only install and set up the pure basics of Vuetify. After the Three.js part will be done, we'll continue our work with UI. So, let's first install Vuetify.

yarn add vuetify

Next, let Vue know that we want to use it, by making some additions to our main.ts file.

// ...
import Vuetify from 'vuetify';
import 'vuetify/dist/vuetify.min.css';

Vue.use(Vuetify);
// ...

new Vue({
  // ...
}).$mount('#app');

We also import the CSS using standard import syntax. Vue CLI has already set it up for us.

Vuetify also requires us to import Roboto fonts (and optionally MD icons font) in order for everything to work and be displayed correctly. For this, we'll edit the <head/> section of our public/index.html file.

<!-- ... -->
<head>
    <!-- ... -->
    <link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons" rel="stylesheet">
</head>
<!-- ... -->

Finally, let's make some clean up in our src directory. Remove all the files we won't use and edit the App.vue to add Vuetify base markup.

<template>
  <v-app>
    <v-content></v-content>
  </v-app>
</template>

Of course, remember to use yarn serve in order to see your changes in real-time.

Three.js

The basic idea behind implementing Three.js in this project is to use Vue as an abstraction layer. Some of our Vue components will only serve the purpose of managing the WebGL scene. You can see a similar concept implemented on a much more advanced level with Mozilla's AFrame - Three.js-based WebVR library in which you manage your 3D scene with a set of custom HTML elements. We could use it, but it may bring some disorder to our code, with us having to keep custom AFrame elements and Vue components separate. Also, the purpose of this project is to learn something new, so...

yarn add three

We'll start by installing Three.js and creating our first <Scene/> component within the src/components directory.

<template>
  <div class="scene" ref="scene"/>
</template>

<script lang="ts">
// ...
</script>

<style scoped>
.scene {
  width: 100%;
  height: 100%;
}
</style>

Our <Scene/> component will contain only one, single <div/> in order to house the Three.js rendering canvas. Now, let's check the full code, shall we?

import { Component, Vue } from "vue-property-decorator";
import * as THREE from "three";

@Component<Scene>({
  mounted() {
    const el = this.$refs.scene as Element;
    this.camera = new THREE.PerspectiveCamera(
      75,
      el.clientWidth / el.clientHeight,
      0.1,
      1000
    );
    this.renderer.setSize(el.clientWidth, el.clientHeight);
    el.appendChild(this.renderer.domElement);

    const geometry = new THREE.BoxGeometry(1, 1, 1);
    const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
    const cube = new THREE.Mesh(geometry, material);

    this.scene.add(cube);
    this.camera.position.z = 5;
    
    const animate = () => {
      requestAnimationFrame(animate);

      cube.rotation.x += 0.01;
      cube.rotation.y += 0.01;

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

    animate();
  }
})
export default class Scene extends Vue {
  private camera!: THREE.PerspectiveCamera;
  private renderer: THREE.WebGLRenderer = new THREE.WebGLRenderer();
  private scene: THREE.Scene = new THREE.Scene();
}

Phew... that's quite a lot of code. Let's break it down!

What you see above is the most basic example of a green, rotating cube, taken straight from Three.js documentation. Of course, it's slightly changed and adapted to our Vue + TypeScript code-base.

First, take a look at the bottom, cause it's here where it all starts. We declare some base properties of our class. We make all of them private, as no one needs to use them besides us and we also note that the camera will be assigned later (using !).

Then, in the mounted callback, we do the basic Three.js workflow. Notice the use of generics at the very top, with a @Component decorator - it allows us to keep our code type-safe, as decorators still don't have too good support in TypeScript. Then, the mounted() hook cannot be declared inside the class itself - whether with additional decorators or not. It just has to be where it is right now - just like all the other lifecycle hooks.

The code inside mounted() hook is just accessing the scene element though reference, a setup of basic Three.js scene, together with camera and an animated cube.

After all, is done in  component, we need to get back to the App.vue file and connect these two together.

import { Component, Vue } from "vue-property-decorator";
import Scene from "./components/Scene.vue";

@Component({
  components: {
    Scene
  }
})
export default class App extends Vue {}

We import our component and use the components property in our @Component decorator's config to specify that we're using it. Then, we can safely use its markup in our template.

<template>
  <v-app>
    <v-content>
        <Scene/>
    </v-content>
  </v-app>
</template>

And... we should have our Three.js scene up and running!

It's just the beginning...

I know that the end result might not be mind-blowing, but hey! - We're just getting started! In the next post, I'm planning on creating the actual board, so, if you liked this post, stay tuned for that! Oh, and you can find all the code from this part of the tutorial here, on GitHub.

As always, let me know what you think down in the comments and the reaction section below. Follow me on Twitter, on my Facebook page or sign up for the weekly newsletter to stay up-to-date with the latest content. Thank you for reading, and have a great day!