You can view the pretty version of the notes here.
- Tweening: Tweening will allow us to interpolate a value within a range over time.
- Timers: We will learn how to make things happen within a range of time and/or after a certain amount of time has passed.
- Solving Matches: This is the heart of Match 3; we'll discuss how we can actually solve matches in order to progress the game.
- Procedural Grids: We'll discuss how to randomly generate levels using procedural grids for our Match 3 game.
- Sprite Art and Palettes: This is a fundamental part of 2D game development. We'll discuss how to create sprites and colour palettes for our game.
Match-3 has taken several forms over the years, with its roots in games like Tetris in the 80s. Bejeweled, in 2001, is probably the most recognized version of this game, as well as Candy Crush from 2012, though all these games owe Shariki, a DOS game from 1994, for their inspiration. The goal of the game is to match any three tiles of the same pattern by swapping any two adjacent tiles; when three or more tiles match in a line, those tiles add to the player's score and are removed from play, with new tiles coming from the ceiling to replace them.
Images courtesy of Medium
-
Clone the repo (or download the zip) for today's lecture, which you can find here.
-
Open the repo in Visual Studio Code.
-
Instead of running a server manually and having to refresh the browser tab every time you want to see your changes, you can now install Visual Studio Code's "Live Server" extension and have it all be taken care of for you:
-
Click on the extensions icons in the left-hand side navigation.
-
Search for "Live Server".
-
Click install next to the extension by "Ritwick Dey". You may have to reload the window.
-
Once it's installed, click "Go Live" on the bottom right of the window. This should start the server and automatically open a new tab in your browser at
http://127.0.0.1:5500/(or whatever URL/port it says in VSC).- The files the server serves will be relative to the directory you had open in VSC when you hit "Go Live".
-
-
Alternatively, you can run the server manually without installing "Live Server":
- Open the VSC terminal (
CTRL + `) and runnpx http-server(assuming you have NodeJS installed, if you don't, download and install it from here) inside the root folder of the repo. - In your browser, navigate to
http://localhost:8080(or whatever the URL is that is displayed in the terminal).
- Open the VSC terminal (
Match-3-0 builds the board and populates it with randomly generated tiles.
-
A colour palette is a set of colours chosen by the designer to give the project a consistent aesthetic throughout.
-
The sprite sheets and assets we have been using in this course were created using DB32 - DawnBringer's 32 Color Palette (V1.0):
Image courtesy of PixelJoint
-
The idea behind using a palette is that it allows you to dither (i.e., interleave two colours pixel by pixel to approximate another colour) such that your games can look like they use many colours, when in reality you only have a small set (in this case 32) available to you.
Image courtesy of Wikipedia
-
When applied in the proper context, the difference between a dithered image and an image that actually uses hundreds of thousands of colours can be quite minimal:
Left image has 101 845 colorus, right image has 32.
-
However, the more realistic the original image is, the less effective dithering becomes:
Left image has 40 144 colours, right image has 32.
-
Palette swapping is the practice of using the same sprite for two different graphics, but with a different palette for each, such that the resulting sprites are noticeably distinct from one another.
-
For example, a bush and a cloud could share the same sprite π€―:
Image courtesy of TodayIFoundOut
If we go back to Breakout-8 for a moment and look at the file structure, we can see that we jammed everything into the src folder. This worked fine for games like Flappy Bird and Breakout, but as our games become more complex and the number of files grows, this will get unmanageable. Take a look at Match-3-0 and examine the file structure:
Match-3-0
β index.html
β
ββββassets
β ββββimages
β sprite_sheet.png
β
ββββlib
β DrawingHelpers.js
β Game.js
β Graphic.js
β Images.js
β RandomNumberHelpers.js
β Sprite.js
β State.js
β StateMachine.js
β
ββββsrc
β config.json
β enums.js
β globals.js
β main.js
β
ββββobjects
β Board.js
β Tile.js
β
ββββstates
PlayState.js
assets/: Where we will keep all the fonts/sounds/images for the game.lib/: All of the files that help us make a game, but are not specifically tied to one game in particular. In other words, the files in here are the framework we're building with which to create games.src/: All of the code that is specific to the game.config.json: Contains configuration data required to load in the game assets. Before, we were doing this inside ofglobals.js. We always want to think about "separation of concerns" in software development, and game development is no exception.globals.jswas getting too bloated, so we decided to separate the specific configuration for the game from the actual global variables.enums.js: Too many magic strings and numbers in your program? Enums to the rescue! Here we define enums for things like state names, sound names, and image names. In particular for Match 3, we will also have our tile colours and patterns in here as well.globals.js: The variables that could potentially be used anywhere in our game.main.js: The entrypoint and bootstrap for the game. In software development, the term bootstrap generally refers to the place in your code that things are loaded/initialized for the first time before executing the main part of the program. For us, that means loading all the assets and states into the state machine. Then, we create a newGameobject, pass in the state machine, and we're ready to play!objects/: Game objects are the various components of our game. In the case of Match 3, we have aBoardthat is made up of manyTileobjects.states/: This should be familiar to you by now! This folder will contain all of the different states for our game which will be contained in the global state machine.
-
In
Board.jswe're generating our tile sprites by calling our helper method,generateSprites(), fromTile.js:generateSprites() { const ROWS = 9; const COLUMNS = 6; const SPLIT_POINT = 2; const sprites = []; let x = 0; let y = 0; let counter = 0; for (let row = 0; row < ROWS; row++) { for (let i = 0; i < SPLIT_POINT; i++) { sprites[counter] = []; for (let column = 0; column < COLUMNS; column++) { const sprite = new Sprite(images.get(ImageName.SpriteSheet), x, y, TILE_SIZE, TILE_SIZE); sprites[counter].push(sprite); x += TILE_SIZE; } counter++; } y += TILE_SIZE; x = 0; } return sprites; }
-
Unlike the bricks in Breakout, the tiles in Match 3 will always remain in a full grid, which we implement as an 8-by-8 2D array. Within the 2D array, we represent each tile as an array containing
xandycoordinates as well as a reference to the tile sprites. We set up the board by calling a helper function,initializeBoard(), from within the board's constructor:initializeBoard() { const colourList = [ TileColour.Beige, TileColour.Pink, TileColour.Purple, TileColour.LightGreen, TileColour.Blue, TileColour.Orange, ]; const patternRange = [0, 0]; const tiles = []; for (let row = 0; row < BOARD_SIZE; row++) { tiles.push([]); for (let column = 0; column < BOARD_SIZE; column++) { const colour = pickRandomElement(colourList); const pattern = getRandomPositiveInteger(patternRange[0], patternRange[1]); const tile = new Tile(column, row, colour, pattern, this.tileSprites); tiles[row].push(tile); } } return tiles; }
-
PlayStatethen calls the board'srender()function to draw the board by looping through the 2D array and drawing each individual tile sprite at itsxandycoordinates.
Match-3-1 allows the player to swap two tiles on the board. This implementation uses keyboard input to traverse the tiles using w, a, s, and d, select tiles with the enter key, and does not enforce any rules for which tiles can be swapped.
-
We've added 2 new variables to
PlayStateto keep track of the highlighting and selection behavior:this.cursor = { boardX: 0, boardY: 0 }; this.selectedTile = null;
- The
cursoris what the player will control to be able to move around the board. - The
selectedTilewill turn brighter when the player hitsenteron the tile the cursor is currently pointing at.
- The
-
updateCursor()monitors whether the player has pressedw,a,s, ord, and if so, we move them around our 2D tile array accordingly:updateCursor() { let x = this.cursor.boardX; let y = this.cursor.boardY; if (keys.w) { y = Math.max(0, this.cursor.boardY - 1); } else if (keys.s) { y = Math.min(BOARD_SIZE - 1, this.cursor.boardY + 1); } else if (keys.a) { x = Math.max(0, this.cursor.boardX - 1); } else if (keys.d) { x = Math.min(BOARD_SIZE - 1, this.cursor.boardX + 1); } this.cursor = this.board.tiles[y][x]; }
-
Recall that the way to index into a 2D array (i.e., an array of arrays) is by specifying
array[row][column], soarray[0][0]would be the top-left element in the 2D array, andarray[1][2]would be the third element in the second row. -
In
PlayState::update()we monitor whether the player has pressed "enter" to select a tile. Once two tiles have been selected, we swap them inBoard::swapTiles()by swapping their place in our 2D array as well as swapping their coordinates on the grid:const temporaryTile = new Tile(selectedTile.boardX, selectedTile.boardY); selectedTile.x = highlightedTile.x; selectedTile.y = highlightedTile.y; highlightedTile.x = temporaryTile.x; highlightedTile.y = temporaryTile.y; selectedTile.boardX = highlightedTile.boardX; selectedTile.boardY = highlightedTile.boardY; highlightedTile.boardX = temporaryTile.boardX; highlightedTile.boardY = temporaryTile.boardY; this.tiles[selectedTile.boardY][selectedTile.boardX] = selectedTile; this.tiles[highlightedTile.boardY][highlightedTile.boardX] = highlightedTile;
-
Finally, in addition to handling the rendering of the board as before,
PlayState::render()now contains some additional logic in order to display the highlighting of selected and highlighted tiles.
Match-3-2 adds the functionality to animate the tiles being swapped on the board. To understand how these animations work, we must first understand the concepts of timers and tweens.
So far we've only had one notion of time in our games, namely, delta time. Since delta time is the time elapsed since the last frame was drawn, we can use this value to implement more concrete timer logic.
Timer-0 renders a blank screen with the following text drawn in the center: "Timer: x seconds", where x is an integer counting up from 0 once per second.
-
In
main.jswe're setting up the game loop as usual. We're not going to use theGameclass for the next few examples since these aren't technically "games". -
update()increments our timer:function update(dt) { secondTimer += dt; if (secondTimer > 1) { currentSecond++; secondTimer %= 1; } render(); }
-
Finally,
render()displays our text and timer to the screen. -
This implementation is not ideal.. Can you think of why?
Timer-1 renders a screen with the text "Counter i: x (every ys)" displayed in five rows, where x is an integer counting up from 0, and y is an integer representing how many seconds have passed between each increment of x.
-
Take a look again at
main.js. This should make the problem with our implementation clearer. This program simulates a situation in which we might need several tasks to perform some action at different times. With the way we've chosen to approach this problem, you'll notice that our code begins to get progressively messier as we introduce additionalcounterandtimervariables:secondTimer += dt; if (secondTimer > 1) { currentSecond++; secondTimer %= 1; } secondTimer2 += dt; if (secondTimer2 > 2) { currentSecond2++; secondTimer2 %= 2; } secondTimer3 += dt; if (secondTimer3 > 4) { currentSecond3++; secondTimer3 %= 4; } // Repeat for as many timers as you may have.
Surely there is a better way to do this than initializing all these variables and cramming our update() with separate logic for each of them?
Timer-2 behaves exactly like Timer-1 but is written with much better design.
- You'll notice at the top of
main.jsthat we are importing aTimerclass, which we've added to our project directory:
import Timer from "./Timer.js";- This will simplify our code greatly, but before we dive into the rest of it, take a moment to familiarize yourself with the following functions:
Timer::addTask(action, interval, duration, callback)action: The function to execute after a certain period of time.interval: How often the action should execute (frequency).duration: How long the task will be tracked in this.tasks.callback: The function to execute after duration has passed.
Task::update(dt)- This function essentially does what we had before in Timer-1's
update()function. After every interval, it executed the action. After the total duration, it executes the callback.
- This function essentially does what we had before in Timer-1's
!> In 99% of cases, you should define each class in their own file. I've made an exception here for Task because it is only ever going to be used in Timer.
-
You'll notice at the top of
main.jsthat we now store all our counters and intervals using one array calledtimerDefinitions, which allows us to loop over our definitions and add each one as a task usingtimer.addTask(), passing in our own custom functions as theactionandcallback. Theactionfunction takes care of incrementing each counter per interval. Thecallbackfunction takes care of changing the output text colour to green once the timer task is complete:const timerDefinitions = [ { interval: 1, duration: 3, counter: 0, colour: 'white' }, { interval: 2, duration: 4, counter: 0, colour: 'white' }, { interval: 4, duration: 8, counter: 0, colour: 'white' }, { interval: 3, duration: 9, counter: 0, colour: 'white' }, { interval: 2, duration: 6, counter: 0, colour: 'white' }, ]; timerDefinitions.forEach((definition) => { const action = () => definition.counter++; const callback = () => definition.colour = 'green'; timer.addTask(action, definition.interval, definition.duration, callback); });
-
Now, our previously cluttered
update()can defer all timer related logic to theTimerclass.function update(dt) { timer.update(dt); render(); }
-
This design is incredibly scalable. Now, if we wanted to add in additional tasks and timers, we need only make a handful of tweaks to our existing array, whereas previously we would've needed to introduce additional overhead in the form of additional variables and conditional checks.
If unfamiliar with the term, "tweening" (short for "in-betweening") refers to "the process of generating intermediate frames between two images to give the appearance that the first image evolves smoothly into the second image".
In other words, tweening simply means changing a value from one number to another over a specified length of time, with the goal of creating the illusion of motion.
Tween-0 renders a screen in which the bird sprite moves across the screen from left to right, with a timer being displayed in the top-left corner.
-
Take note of the
DURATIONconstant we've defined atopmain.js. We use it inupdate()to ensure that our sprite takes approximately that amount of time (in seconds) to move across the screen each time the program is run. -
Also at the top of
main.jswe're creating our bird sprite (note than we've added its image file to our project directory), assigning its initial coordinate values, and initializing anEND_Xconstant to hold the value of thexcoordinate we want our sprite to have at the end of its movement. -
Now we use
Math.min()inupdate():function update(dt) { if (timer < DURATION) { timer += dt; flappyX = Math.min(END_X, END_X * (timer / DURATION)); } }
- By using
Math.min()here, we ensure that we don't go past our desiredEND_X. Ourtimervariable increases bydteach frame, sotimer / DURATIONis the ratio by which we multiply ourEND_Xeach frame to make it seem as if we're moving towards the right during the specifiedDURATIONlength.
- By using
-
This program works, but just like Timer-0, it is not the optimal implementation. Take a moment to brainstorm for a potentially better approachβ¦
Tween-1 operates under the same premise as Tween-0, but instead of moving one sprite across the screen, it moves 1000 sprites across the screen, each at different rates, with the same timer.
-
At the top of
main.jswe set up the sprites on the screen looping through abirdsarray. This loop also sets up an individualrateandyposition for each bird:const birds = []; for (let i = 0; i < 1000; i++) { birds.push({ x: 0, y: Math.random() * (canvas.height - flappy.height), rate: Math.random() * DURATION, }) }
-
Each bird's
rateandyposition is calculated randomly. Recall thatMath.random()by itself will generate a random float between0and1, so we have to multiply the resulting value by the maximum number we want the random number to be. -
In
update()we loop over the birds and move each sprite across the screen using its individualrate:function update(dt) { if (timer < DURATION) { timer += dt; birds.forEach((bird) => { bird.x = Math.min(END_X, END_X * (timer / bird.rate)); }); } }
-
Again, this is a more scalable design, which is (usually) always better. However, the logic here is specific to a bird moving horizontally across the screen. If we had many birds moving in different directions, we would have to write a different implementation per bird.
Tween-2 behaves similarly to Tween-1 but now we're using the Timer::tween() function, which will allow us to interpolate between any 2 values and have the implementation be uncoupled from the scenario.
Timer.tween(object, parameters, endValues, duration, callback)- Interpolates
parameterson a specifiedobjectover someduration, where theendValuesare the final values of the interpolation, and a finalcallbackis executed once thedurationhas passed.
- Interpolates
-
This time in
main.js:- We've introduced an opacity value for our birds which, when tweened, will look like the bird is fading in as it travels across the screen.
- The starting and end position of each bird is randomized to demonstrate that we can tween any values now.
- We pass in a
callbacktotimer.tween()that will change the bird's sprite from red to green when the tween is done.
birds.forEach((bird) => { timer.tween( bird, ['x', 'y', 'opacity'], [bird.endX, bird.endY, 1], bird.duration, () => bird.sprite = flappyGreen, ); });
-
This allows us to defer our
update()function to theTimerclass:function update(dt) { timer.update(dt); render(); }
Hopefully, you can appreciate how easy it is to tween values now, and you can start to see different ways that tweening can be used in a game.
With our new knowledge of timers and tweening, we can now implement the new Timer in the game so that the swapping of two tiles is animated!
-
First, let's declare a global
timeringlobals.js:export const timer = new Timer();
-
Next, in
Board.js, we replace the position swapping code with calls totimer.tween():timer.tweenAsync(highlightedTile, ['x', 'y'], [temporaryTile.x, temporaryTile.y], 0.2); await timer.tweenAsync(selectedTile, ['x', 'y'], [highlightedTile.x, highlightedTile.y], 0.2);
- We need to
awaitthe tween because we want the animation to finish before proceeding with the rest of the function.
- We need to
-
Finally, we have to make sure we're updating the
timersomewhere so that it can interpolate the tile coordinates on every frame. One good place to do that isPlayState::update():timer.update(dt);
Match-3-3 implements logic to detect matches of 3 or more tiles in a row or column.
- Starting from the top-left corner of the board (i.e. the first tile), set
colourToMatchto the colour of the current tile. - Then, check the adjacent tile on the right (i.e. the second tile).
- If the second tile is of the same colour as
colourToMatch, incrementmatchCounter. - If not, set
colourToMatchto the colour of the second tile and resetmatchCounterto1.
- If the second tile is of the same colour as
- Repeat this process for the subsequent tiles in that row. As soon as we reach an adjacent tile of a different colour, or the end of the row, if
matchCounter >= 3, we add that group of tiles to amatchesarray. - After all rows have been checked for matches, this process is repeated for each column, going from top to bottom.
- The result of our algorithm is a 2D array containing each array of matches, both horizontal and vertical, with each array containing an array of the tiles in the match.
-
Find the implementation of the algorithm in
Board::calculateMatches(). -
We call
Board::initializeBoard()to make sure we start with a board with no matches:this.calculateMatches(); while (this.matches.length > 0) { this.initializeBoard(); }
-
We also call this method in
PlayState::selectTile()since we want to check if there are any matches after a tile swap:await this.board.swapTiles(this.selectedTile, highlightedTile); this.selectedTile = null; this.board.calculateMatches();
Match-3-4 takes care of removing and replacing the tiles that have been detected as a match.
Now the question is how to remove our matching tiles, once we have them all in an array. You might think we'd have to do some tricky coding with their coordinates on the grid or their position on the board, but this problem is solved easier than you may think.
- In
Board::removeMatches(), we loop over each array within our matches array and set the value of each tile tonull, after which we can also set our matches array tonull.- This has the effect of erasing the matched tiles array from existence. The result on the screen would be the board as it was before, but with holes where previously there would've been matches.
- The next function in the file,
Board::getFallingTiles(), takes care of shifting down the remaining tiles in each column if needed.- Start from the bottom of each column and check each slot in the column until a
nullslot is found. - Once a
nullslot is found, its position is marked and the next non-nulltile found in the column is tweened down to the marked position. - This process is iterated for each
nullslot in each column.
- Start from the bottom of each column and check each slot in the column until a
- After we've removed matching tiles and shifted down those left over, we need to figure out how to replace the remaining
nullpositions. This is done inBoard::getNewTiles().- For each column, we count the number of
nullspaces and generate that many new tiles. - Then, we can set each tile's position to be that of one of the
nullspaces, and add it to our board array. - We want the animation to look like the tiles are falling into place, however, so to produce this effect, we tween each new tile from the top of the board to its final position using the
Timerclass.
- For each column, we count the number of
- The possibility exists that the newly-generated tiles will create additional matches when they spawn, so, check for matches again (recursively) after generating the new tiles.
- This is done in
PlayState::calculateMatches()function, which uses the functions inBoard.jsto calculate matches, remove matches, and get falling tiles, and then recurses until it ensures that no new matches have been found.
- This is done in
PlayState::calculateMatches()- Calculates whether any matches were found on the board.
- If so, it removes the matched tiles and tweens the tiles above the matches to their new destinations.
- Generates new tiles to fill in all the new spaces and tween them to their location on the board.
- Calls itself recursively to resolve any new matches that may have resulted from the new tiles.
Board::removeMatches()- Removes the matches from the board by setting the tile slots within them to
null, then settingthis.matchesto empty.
- Removes the matches from the board by setting the tile slots within them to
Board::getFallingTiles()- Shifts down all of the tiles that now have spaces below them, then returns an array that contains tweening information for these new tiles.
Board::getNewTiles()- Scans the board for empty spaces and generates new tiles for each space.
Match-3-5 adds scoring, levels, and a timer, such that when the player reaches a certain score on a given level before time runs out, they will go to the next level.
-
PlayStatehas additional member variables to keep track of thescore,level, andtimer.-
The
scoreGoalindicates to the player how many points they need in order to ascend to the next level. This value scales byscoreGoalScalebased on the current level. -
The
scoreis incremented inPlayState::calculateScore():this.board.matches.forEach((match) => { this.score += match.length * this.baseScore; });
-
The
timeris decremented every second and the player must try and reach thescoreGoalbefore time runs out.// PlayState.js startTimer() { timer.addTask(() => { this.timer--; if (this.timer <= 5) { sounds.clock.play(); } }, 1, this.maxTimer); }
-
If the player reaches
scoreGoalbefore time run out, they ascend to the next level:// PlayState.js checkVictory() { if (this.score < this.scoreGoal) { return; } stateMachine.change(StateName.Play, { level: this.level + 1, score: this.score, }); }
-
-
We've added a new state called
GameOverState. This prints "Game Over" on the screen as well as the player's score when the timer reaches zero. InPlayState, we check if time has run out:checkGameOver() { if (this.timer > 0) { return; } stateMachine.change(StateName.GameOver, { score: this.score, }); }
-
Lastly, we utilize the state's
exit()function to clear the timer:exit() { timer.clear(); }
Match-3-6 adds an animated title screen, animated level transitions, and music. As of right now, we can swap any two tiles on the board. To make this more true to a Match 3 game, we'll add a limit to only allow the player to swap two adjacent tiles.
-
Let's start with the new
TitleScreenState. We've seen title screens in previous games, and this one is not much different.-
The animated title text is done by keeping the individual letters in an array, and keeping the individual colour values in another array. We then set up a timer to shift the values of the colours array over by 1 so that when the text is rendered, it appears as if the colours are moving from letter to letter:
// TitleScreenState::constructor() this.titleLetters = ['M', 'A', 'T', 'C', 'H', '3']; this.colours = [ [217, 87, 99, 255], [95, 205, 228, 255], [251, 242, 54, 255], [118, 66, 138, 255], [153, 229, 80, 255], [223, 113, 38, 255], ]; // TitleScreenState::startColourTimer() this.colourTimer = timer.addTask(() => { this.colours = this.colours.slice(1).concat(this.colours.slice(0, 1)); }, 0.1); // TitleScreenState::drawTitleText() for (let i = 0; i < this.titleLetters.length; i++) { const r = this.colours[i][0]; const g = this.colours[i][1]; const b = this.colours[i][2]; context.fillStyle = `rgb(${r}, ${g}, ${b})`; context.fillText(this.titleLetters[i][0], CANVAS_WIDTH / 2 + 50 * i - 130, CANVAS_HEIGHT / 2 + offSet + 55); }
-
The animated background is done by creating a
new Board()and passing in larger dimensions than the board that is used for play. There is a new functionBoard::autoSwap()that uses the timer to randomly swap two tiles. This was created for no other reason than to provide a more interesting background to look at.
-
-
Next, we made a new
LevelTransitionStatethat is in charge of animating a "level label" that comes down from the top of the screen.- There is too much code to paste here, so please take a look at
LevelTransitionStateand make sure you understand how these animations are being done. We're making heavy use of timers and tweens, and hopefully you can start to imagine all the cool different things you can do with thisTimerclass!
- There is too much code to paste here, so please take a look at
-
Next, we've added music. The only difference here from our previous games is that we have multiple music tracks. The currently playing track is different depending on which state you're in. As I'm sure you're aware, it is extremely common for games to have many different music tracks based on what state/level you are currently on.
-
For example, we switch tracks like this when changing states:
// TitleScreenState.js exit() { sounds.pause(SoundName.Music); } // LevelTransitionState.js enter() { sounds.play(SoundName.Music3); }
-
-
Lastly, we'll limit the player to only be able to swap adjacent tiles. To accomplish this, we take the board distance between the selected tile and the highlighted tile and check if it's greater than one. If so, that means the player is trying to swap two tiles that are more than one tile's size apart.
const tileDistance = Math.abs(this.selectedTile?.boardX - highlightedTile.boardX) + Math.abs(this.selectedTile?.boardY - highlightedTile.boardY); if (tileDistance > 1) { sounds.play(SoundName.Error); this.selectedTile = null; }
- The
?.syntax is called optional chaining which allows you to check a property on an object even if that object isnullat the time.
- The
And with that, we have a fully functioning game of Match 3!







