In this blog post we’ll explore how we can build the classic Snake game using Lightning Web Components (LWC). We’ll also explore how we can optimize the performance of our components and deploy them to any platform of our choice. We built this game during a Trailhead Live session. Check out the recording here.

The below image shows how the completed game looks. You can check out the source code here or play the game on Heroku or GitHub pages. You can also install it on your desktop and play offline.

The guiding principle when building this game is the fact that ” Using JavaScript to manipulate the DOM isn’t recommended because the Lightning Web Components engine does it more efficiently“. We also won’t use the HTML5 Canvas because the only way to modify the contents of a canvas is using the Canvas API which is unrelated to Lightning Web Components and doesn’t leverage its benefits.

With this in mind, let’s dive in. We’ll start by creating the game area, to which we’ll add the snake and the food. We’ll then add the logic to move the snake based on keyboard controls and make the snake longer every time it eats the food. Finally, we’ll deploy the game to GitHub Pages and Heroku.

Creating the game area

At the heart of the game is the area on which the snake moves. Since we aren’t going to use the HTML5 Canvas, the trick is to divide the available space into blocks of equal size, where each block is a div element.

To calculate the number of blocks needed to cover the area, we divide the available area by the block size. To get the available area we can use the clientWidth and clientHeight properties of the HTMLElement.

const gameContainerEl = this.template.querySelector('.game-container');
const eWidth= gameContainerEl.clientWidth; const eHeight= gameContainerEl.clientHeight;

Once we’ve identified the number of blocks needed to cover the area, the next step is to actually create the blocks. One approach is to create a div element for each block using JavaScript and adding it to the DOM, but this would involve manipulating the DOM using JavaScript which is not efficient.

A better way to do it is to create an array of objects, where each object represents a block. We can then iterate over these objects in the template using the for:each directive to render the blocks. Each block is given an ID which we can use throughout the game to find that block. This ID is made up of the X and Y coordinates of the block in the form of X:Y.

@track gameBlocks = [];

renderedCallback(){
    for (let y = 0; y < this.yMax; y++) {
        for (let x = 0; x < this.xMax; x++) {
            ...
            obj = { id: `${x}:${y}` };
            ...
            this.gameBlocks.push(obj);
        }
    }
}
<template for:each={gameBlocks} for:item="block">
    <div key={block.id}>
        ...
    </div>
</template>

This presents an interesting problem. If we use @track on a property, then changing it in the renderedCallback() will create an infinite loop of rendering cycles. To fix this, we can add a limiter using a boolean variable. But that doesn’t solve the problem completely.

Every time we push a new element to the array inside a for-loop, a rerender is triggered, which impacts the performance. To overcome this problem, we can create a temporary array that holds all the elements until the for-loop is complete. We can then simply assign its final value to the main array. This approach even lets us get rid of @track in the first place.

gameBlocks = [];
 gameRendered= false;

renderedCallback(){
    if(!this.gameRendered){
        const tmpBlocks= []; //Declaring a temporary array for (let y = 0; y < this.yMax; y++) { for (let x = 0; x < this.xMax; x++) { obj = { id: `${x}:${y}`}; } tmpBlocks.push(obj); } this.gameRendered = true; //assigning the temporary array to the main one this.gameBlocks = tmpBlocks; } }

Creating the snake and food

The next step is to show the snake and food in the game area. One of the ways we can do this is to use CSS classes to color the div blocks where the snake and food are to be shown. We can then keep adding and removing these CSS classes to each block to create the effect of the snake moving.

We can add new properties to the block objects to denote if a block is the snake, food, or is empty.

...
obj = { id: `${x}:${y}`, snake: true, food: false };
...

In the template, we can use the if:true directive to decide which type of block we want to show.

<template for:each={gameBlocks} for:item="block">
     <template if:true={block.snake}>
        <div class="snake" key={block.id}></div>
     </template>
     <template if:true={block.food}>
        <div class="food" key={block.id}></div>
     </template>
     ...
</template>

This approach works but causes slight performance degradation. This is because each time the snake moves, the if:true template tags have to be evaluated and the corresponding DOM nodes have to be created/destroyed which is an expensive operation. So to improve performance, we can create another property called class on the block object, and assign its value to the class attribute of the div element. This simple update of an element’s attribute value results in better performance as there are lesser evaluations.

...
obj = { id: `${x}:${y}`, snake: true, food: false, class: 'snake' };
...
<template for:each={gameBlocks} for:item="block">
     <div class={block.class} key={block.id}></div>
</template>

Now moving on to creating the food. Since food is placed randomly on the game area, we can use the Math.random() function to randomly decide the div block where the food must be present.

const xFood = Math.floor(Math.random() * (this.xMax - 1));
const yFood = Math.floor(Math.random() * (this.yMax - 1));

const foodPosIndex = this.gameBlocks.findIndex(
    (x) => x.id === `${xFood}:${yFood}`
);
this.gameBlocks[foodPosIndex].food = true;
this.gameBlocks[foodPosIndex].class = 'food';

Moving the snake and making it longer

The snake can be moved across the game area by incrementing the position of the head of the snake based on the direction it’s moving. For example, if the snake is moving right, then the X value of the head is incremented by 1, and Y value stays 0.

xSpeed = 1;
ySpeed = 0; 

xHead = 0;
yHead = 0;     
        
move() {
   this.xHead += this.xSpeed;
    this.yHead += this.ySpeed;
}

To change the direction of the snake, we just need to update the xSpeed and ySpeed properties based on what arrow key is pressed.

window.addEventListener('keydown', (e) => {
    e.preventDefault();
    switch (e.key) {
        case 'ArrowUp':
            this.xSpeed = 0;
            this.ySpeed = -1;
            break;
        case 'ArrowDown':
       ...
       
       }
});

Finally, the move() function can be called at regular intervals depending on how fast we want the snake to move. We can call this whenever we want to start the game. For example, on click of a “Start” button, on page load, and so on.

setInterval(() => {
   this.move();
}, 300);

The trickiest part of creating the game is to increase the length of the snake’s tail whenever it eats the food. To achieve this, we’ll need to store the block IDs of the tail in a new array. To move the tail along with the head, we’ll need to remove the first element of the tail array and add the current position of the head to the end of the tail array. Whenever the snake eats the food, we just don’t remove the first element of the array.

move(){
    // Moving the tail. 
    // Removing the first element only if food hasnt been eaten in the previous step
    const lastElement= this.tail[this.tail.length - 1]; if (lastElement!== `${this.xHead}:${this.yHead}`) { this.tail.push(`${this.xHead}:${this.yHead}`); const removedElement = this.tail.shift(); const curPosIndex = this.gameBlocks.findIndex( (x) => x.id === removedElement ); this.gameBlocks[curPosIndex].snake = false; } // Adding the current head to the tail if food is eaten // so that in the next step we can determine if the snake ate food in the previous step if (this.gameBlocks[newPosIndex].food) { this.score++; this.tail.push(`${this.xHead}:${this.yHead}`); ... } }

Deploying to multiple platforms

To deploy the game to multiple platforms, we’ll need to create a new Lightning Web Components Open Source (LWC OSS) project and add the game component to it. Depending on where we want to deploy the app, we can choose one of the different app types when creating the project – Standard, Progressive Web App (PWA), or Electron App.

The LWC OSS project includes the tools and services we need to build, test, and run our project locally. Each of these tools and services can be configured based on our needs using the corresponding config files. For example, the lwc-services.config.json file lets us configure settings like the directory for the build output, port numbers for development and production servers, as well as many more. The scripts property in the package.json file lists all the scripts we can run on the project. For example, the watch script runs the project locally in “development” mode, meaning that any changes to the source files will auto-refresh the browser. The serve script on the other hand runs the project in “production” mode, where the executable files are served from the build directory.

During our Trailhead Live session, we created a PWA so that it can run on the browser, and can be installed on your desktop to be run offline. We also deployed the project to GitHub pages and Heroku. There are many ways to do this, and we picked the easiest one which involves pushing the code to GitHub and triggering the deployments to both environments from there.

GitHub Pages

GitHub Pages needs the executable files to be present either in the docs folder or the gh-pages branch. We can choose one or the other based on our preference.

During the Trailhead Live session, we chose to use the docs folder, which means that the build output of the project must go into the docs folder. To do this, we’ll need to update the lwc-services.config.json to configure the build directory ( buildDir) to be docs. This is how the updated file looks:

module.exports = {
    buildDir: './docs',
    resources: [{ from: 'src/resources/', to: 'docs/resources/' }]
};

Once this is done, we can run the command npm run build using Command Line or Terminal, which will create the docs folder with the required files. build is one of the scripts in the package.json file that creates the executable files in the configured build folder. We can then push the folder to GitHub, and enable GitHub Pages from the Settings menu as shown below.


This auto triggers a deployment to GitHub Pages. Alternately, we can also create GitHub actions to automatically run the build command and update the docs folder or gh-pages branch whenever code is pushed to the repo.

Heroku

To deploy the same code to Heroku, we’ll need to create a file with the name Procfile in the root of the project, with the below contents.

web: npm run serve

The serve script from package.json spins up an Express server to serve the static HTML content from the build directory. Since we configured the build directory to be docs in the previous step, we also need to update the scripts/server.js file to reflect the same.

...
const DIST_DIR= './docs'; ...

These changes can then be committed to the GitHub Repo. We can then create a new app on Heroku, select GitHub as the Deployment Method, choose your GitHub repo, and enable Automatic Deploys.

This way, once we push code to GitHub, it automatically deploys to both GitHub pages and Heroku.

Summary

Building a game is a great and fun way to dive deep into Lightning Web Components. As we have seen, there can be multiple ways to achieve the desired result, but not every way lead to optimal performance. Also, since the apps we build with Lightning Web Components are truly reusable across different platforms, there is no better time than now to start thinking of scaling your solutions outside the Salesforce Ecosystem.

Hopefully, this has sparked your imagination, and inspired you to build other games. Hint: Other classic games like Tetris, Ludo, and many more. We can’t wait to see what you build. Also, don’t forget to check out the source code of this game. Feel free to fork it, raise issues, or submit a PR to improve the game.

About the author

Aditya Naag Topalli is a 13x Certified Lead Developer Evangelist at Salesforce. He focuses on Lightning Web Components, Einstein Platform Services, and integrations. He writes technical content and speaks frequently at webinars and conferences around the world. Follow him on Twitter @adityanaag.

Please click here to read the original article as posted on Salesforce Developers Blog.

We source the web to bring you best Salesforce articles for our reader’s convenience. If you want to have this article removed, please follow guidelines at Digital Millennium Copyright Act (DMCA)..

LEAVE A REPLY

Please enter your comment!
Please enter your name here