Before we start with the tutorial on how to create a match-3 game with Felgo, we should focus on the basic idea behind such games. I think most of you already know games like Candy Crush Saga or even got invited to play such games. The main concept is simple:
The game continues like that until a certain criteria is reached, for example:
We will stop our game when there are no groups left to remove. When this happens, we show the player score and provide a button to start a new game. The player can remove a group by clicking it and gets points based on the number of elements in the group.
When you create the game with this tutorial you will learn how to:
If you are not yet familiar with the basic setup of projects, QML or how to create your own entities, please consider taking a look at the following tutorial to help you get started:
Now we are ready to go! But first things first, we still need to set the theme for our match-3 game: In the game Juicy Squash we want to mix the most delicious smoothie in the world! So let's fill the blender in our kitchen with all kinds of fruits and squash them to reach the perfect juicy score! ;-)
We also provide the full source code of Juicy Squash made with Felgo in the Felgo SDK. See here where to find the demo in your SDK directory.
You can find two game versions there:
All the resources of the game should be placed within the assets directory of the project. You can download them here.
Let us start with creating a new empty project in QtCreator. We will use the portrait mode as interface orientation. After you finish the project setup, please add the downloaded resources to the assets folder of your
project. They should appear in the Other files\assets
directory of the project tree. Please take care not to add an additional subdirectory that might be created when you unpack the resources archive.
That's all the setup we need, let's start to implement the game!
The first thing we want to accomplish is to create the fruits and place them within a grid. But before we add any new entities and complex calculations, you should replace your Main.qml
with the following
implementation.
qml/Main.qml
:
import Felgo 4.0 import QtQuick 2.0 GameWindow { id: gameWindow activeScene: scene // the size of the Window can be changed at runtime by pressing Ctrl (or Cmd on Mac) + the number keys 1-8 // the content of the logical scene size (480x320 for landscape mode by default) gets scaled to the window size based on the scaleMode // you can set this size to any resolution you would like your project to start with, most of the times the one of your main target device // this resolution is for iPhone 4 & iPhone 4S screenWidth: 640 screenHeight: 960 // custom font loading of ttf fonts FontLoader { id: gameFont source: "../assets/fonts/akaDylan Plain.ttf" } Scene { id: scene // the "logical size" - the scene content is auto-scaled to match the GameWindow size width: 320 height: 480 // property to hold game score property int score // background image MultiResolutionImage { source: "../assets/JuicyBackground.png" anchors.centerIn: scene.gameWindowAnchorItem } // display score Text { // set font font.family: gameFont.name font.pixelSize: 12 color: "red" text: scene.score // set position anchors.horizontalCenter: parent.horizontalCenter y: 446 } } }
These few lines will provide a basic design for our game. We define a MultiResolutionImage that already shows the game layout. This image is bigger than the actual scene size we defined. On different devices with other screen ratios more of the background image will show in order to avoid dark borders. Please check out the tutorial on scaling modes and support of multiple screen sizes if you are interested in this topic.
We also display the player score in our own custom font. This is achieved by adding the FontLoader component and setting the source to our font in the
assets
directory.
Let's hit play and see if everything works fine up to this point! Your screen should look like that:
Now let's move on to the fruits we want to add. We have many different kinds of fruits in the game, but beneath the different appearance they share the same game logic. We can treat them as a single game entity with
different visual representations. Just create a new file Block.qml
within your qml
folder and add the following lines of code.
qml/Block.qml
:
import Felgo 4.0 import QtQuick 2.0 EntityBase { id: block entityType: "block" // each block knows its type and its position on the field property int type property int row property int column // emit a signal when block is clicked signal clicked(int row, int column, int type) // show different images for block types Image { anchors.fill: parent source: { if (type == 0) return "../assets/Apple.png" else if(type == 1) return "../assets/Banana.png" else if (type == 2) return "../assets/Orange.png" else if (type == 3) return "../assets/Pear.png" else return "../assets/BlueBerry.png" } } // handle click event on blocks (trigger clicked signal) MouseArea { anchors.fill: parent onClicked: parent.clicked(row, column, type) } }
The main idea is to set up each block as an independent entity that knows its position on the grid and its type. Based on the value of the type
property we simply show a different fruit image. In order to listen
to clicks on a fruit by the player, we added a MouseArea that covers the whole item. Whenever a block is clicked, it emits a signal that holds all the relevant
information. This way we can conveniently use a single function to handle the clicked signals from all the blocks in the game. And furthermore, we directly know at which grid position the click occurred and what type of fruit
is at that position.
The fruits are now ready to be created and added to the game, but we will need a lot of game logic and calculations for the grid. We do not want to directly place all of that within our game scene. To achieve a better
separation of code and clear responsibilities of components we will create an additional item GameArea.qml
to hold the grid of fruits and all the necessary calculations.
qml/GameArea.qml
:
import Felgo 4.0 import QtQuick 2.0 Item { id: gameArea // shall be a multiple of the blockSize // the game field is 8 columns by 12 rows big width: blockSize * 8 height: blockSize * 12 // properties for game area configuration property double blockSize property int rows: Math.floor(height / blockSize) property int columns: Math.floor(width / blockSize) // array for handling game field property var field: [] // game over signal signal gameOver() // calculate field index function index(row, column) { return row * columns + column } // fill game field with blocks function initializeField() { // clear field clearField() // fill field for(var i = 0; i < rows; i++) { for(var j = 0; j < columns; j++) { gameArea.field[index(i, j)] = createBlock(i, j) } } } // clear game field function clearField() { // remove entities for(var i = 0; i < gameArea.field.length; i++) { var block = gameArea.field[i] if(block !== null) { entityManager.removeEntityById(block.entityId) } } gameArea.field = [] } // create a new block at specific position function createBlock(row, column) { // configure block var entityProperties = { width: blockSize, height: blockSize, x: column * blockSize, y: row * blockSize, type: Math.floor(Math.random() * 5), // random type row: row, column: column } // add block to game area var id = entityManager.createEntityFromUrlWithProperties( Qt.resolvedUrl("Block.qml"), entityProperties) // link click signal from block to handler function var entity = entityManager.getEntityById(id) entity.clicked.connect(handleClick) return entity } // handle user clicks function handleClick(row, column, type) { // ... } }
At the top of the file we define the size of the game area and some additional properties for the block size (size of fruits) and the total number of rows and columns of the grid. We also already prepare a signal we will use
later to relay the message that the game is over. The most important part of the object is the field
property. It represents the game field (grid) as an array of block-entities. We then have a few functions that
help us to fill our game field with fruits:
index(row, column)
row
and column
).initializeField()
clearField()
createBlock(row, column)
handleClick(row, column, type)
clicked
-signals from the blocks we create. The parameters tell us the position and the type of the block that has been clicked. We connect the signal of our dynamically created blocks with
this function through the entity.clicked.connect(handleClick)
command.Let's add the game area to our scene and trigger the initialization of the game field.
qml/Main.qml
:
GameWindow { id: gameWindow // ... // initialize game when window is fully loaded onSplashScreenFinished: scene.startGame() // for dynamic creation of entities EntityManager { id: entityManager entityContainer: gameArea } // custom font loading of ttf fonts FontLoader { id: gameFont source: "../assets/fonts/akaDylan Plain.ttf" } Scene { id: scene // ... // game area holds game field with blocks GameArea { id: gameArea anchors.horizontalCenter: scene.horizontalCenter blockSize: 30 y: 20 } // initialize game function startGame() { gameArea.initializeField() scene.score = 0 } } }
We also add the EntityManager component we are using in the createBlock
-function and specify the game area as the entityContainer
for all the blocks. The
startGame
-function resets the points and the game field. It will be called each time a new game has to be started. The line onSplashScreenFinished: scene.startGame()
will trigger the very first
initialization after the Felgo splash screen is gone. When you start the game at this point you should already see the grid filled with fruits! Looks delicious!
This tutorial is a guide on how to create a match-3 game, so we want to find out when three or more fruits are together. When creating the fruits, we already connect their clicked
signal to our handler function
by stating entity.clicked.connect(handleClick)
. We are then going to check all the blocks that are neighbors to the one we clicked. If more than three blocks of the same type are connected, they will be
removed.
qml/GameArea.qml
:
Item { id: gameArea // ... function handleClick(row, column, type) { // copy current field, allows us to change the array without modifying the real game field // this simplifies the algorithms to search for connected blocks and their removal var fieldCopy = field.slice() // count and delete connected blocks var blockCount = getNumberOfConnectedBlocks(fieldCopy, row, column, type) if(blockCount >= 3) { removeConnectedBlocks(fieldCopy) } } // recursively check a block and its neighbors // returns number of connected blocks function getNumberOfConnectedBlocks(fieldCopy, row, column, type) { // stop recursion if out of bounds if(row >= rows || column >= columns || row < 0 || column < 0) return 0 // get block var block = fieldCopy[index(row, column)] // stop if block was already checked if(block === null) return 0 // stop if block has different type if(block.type !== type) return 0 // block has the required type and was not checked before var count = 1 // remove block from field copy so we can't check it again // also after we finished searching, every correct block we found will leave a null value at its // position in the field copy, which we then use to remove the blocks in the real field array fieldCopy[index(row, column)] = null // check all neighbors of current block and accumulate number of connected blocks // at this point the function calls itself with different parameters // this principle is called "recursion" in programming // each call will result in the function calling itself again until one of the // checks above immediately returns 0 (e.g. out of bounds, different block type, ...) count += getNumberOfConnectedBlocks(fieldCopy, row + 1, column, type) // add number of blocks to the right count += getNumberOfConnectedBlocks(fieldCopy, row, column + 1, type) // add number of blocks below count += getNumberOfConnectedBlocks(fieldCopy, row - 1, column, type) // add number of blocks to the left count += getNumberOfConnectedBlocks(fieldCopy, row, column - 1, type) // add number of bocks above // return number of connected blocks return count } // remove previously marked blocks function removeConnectedBlocks(fieldCopy) { // search for blocks to remove for(var i = 0; i < fieldCopy.length; i++) { if(fieldCopy[i] === null) { // remove block from field var block = gameArea.field[i] if(block !== null) { gameArea.field[i] = null entityManager.removeEntityById(block.entityId) } } } } }
The first step is to implement the handleClick
-function. We get the position and the type of the block that was clicked as parameters of the function. We then go on with copying the whole current game field into
a new local variable fieldCopy
. For this purpose the JavaScript function slice()
is used, which is one of the fastest ways to copy a whole array. We will need this copy later when we search for and
remove connected blocks.
The most important function here is getNumberOfConnectedBlocks
. It calculates the number of connected blocks based on four parameters:
fieldCopy
row
and column
type
Each call to getNumberOfConnectedBlocks
will return zero if:
row
or the column
parameter is outside of the bounds of our game field.type
.If neither is the case, a valid block has been found. The function then removes this block from the field copy. After that all the neighboring blocks are checked in the same way by calling
getNumberOfConnectedBlocks
again, and the number of all connected blocks in the neighborhood is summed up and returned.
To really grasp the concept behind this algorithm, you should be able to fully understand what it means when a function calls itself within its function body. This concept is called "recursion". Maybe you need to read up a bit more on this topic, but i am positive that you can master it. ;-)
Once we have the result of this awesome function, we can easily react to the number of the connected blocks we found. If there are more than three, we remove them from the actual game field based on the empty spots in the copy of the field.
Note: JavaScript arrays are automatically passed by reference, so the changes made to the field copy by the functions won't be lost. This is necessary, because we work with these changes in other functions like the
additional recursion calls and the removeConnectedBlocks
function.
Press play and you can already start removing blocks!
Now we want to move down higher blocks and create new fruits every time we remove a group. We use a new function moveBlocksToBottom
for that purpose. This function will be called in our clicked-handler after we
removed a group of blocks. In addition, we also calculate and increase the player score to complete our handler function.
qml/GameArea.qml
:
Item { id: gameArea // ... function handleClick(row, column, type) { // copy current field, allows us to change the array without modifying the real game field // this simplifies the algorithms to search for connected blocks and their removal var fieldCopy = field.slice() // count and delete connected blocks var blockCount = getNumberOfConnectedBlocks(fieldCopy, row, column, type) if(blockCount >= 3) { removeConnectedBlocks(fieldCopy) moveBlocksToBottom() // calculate and increase score // this will increase the added score for each block, e.g. four blocks will be 1+2+3+4 = 10 points var score = blockCount * (blockCount + 1) / 2 scene.score += score } } // ... // move remaining blocks to the bottom and fill up columns with new blocks function moveBlocksToBottom() { // check all columns for empty fields for(var col = 0; col < columns; col++) { // start at the bottom of the field for(var row = rows - 1; row >= 0; row--) { // find empty spot in grid if(gameArea.field[index(row, col)] === null) { // find block to move down var moveBlock = null for(var moveRow = row - 1; moveRow >= 0; moveRow--) { moveBlock = gameArea.field[index(moveRow,col)] if(moveBlock !== null) { gameArea.field[index(moveRow,col)] = null gameArea.field[index(row, col)] = moveBlock moveBlock.row = row moveBlock.y = row * gameArea.blockSize break } } // if no block found, fill whole column up with new blocks if(moveBlock === null) { for(var newRow = row; newRow >= 0; newRow--) { var newBlock = createBlock(newRow, col) gameArea.field[index(newRow, col)] = newBlock newBlock.row = newRow newBlock.y = newRow * gameArea.blockSize } // column already filled up, no need to check higher rows again break } } } // end check rows starting from the bottom } // end check columns for empty fields } }
The implementation of the moveBlocksToBottom
-function takes care of the following tasks:
We apply this function after we removed a group of connected blocks. The player score is then increased in a way that considers the number of blocks that have been removed. A group of three blocks will amount to 1+2+3
= 6
points, a group of four will give 1+2+3+4 = 10
points and so on. With these changes you can already start to play and gain points!
A game is no fun if it is never over. Well, in fact the game is already over when there are no more blocks available to remove. At the moment, the user then is stuck with nothing to do. We want to determine if no more groups
of three are existing and trigger our gameover-signal when that happens. We can easily do that by trying the getNumberOfConnectedBlocks
function on all the blocks of the field. We will cover that by adding a new
function isGameOver
to our game area.
qml/GameArea.qml
:
Item { id: gameArea // ... function handleClick(row, column, type) { // ... if(blockCount >= 3) { // ... // emit signal if game is over if(isGameOver()) gameOver() } } // ... // check if game is over function isGameOver() { var gameOver = true // copy field to search for connected blocks without modifying the actual field var fieldCopy = field.slice() // search for connected blocks in field for(var row = 0; row < rows; row++) { for(var col = 0; col < columns; col++) { // test all blocks var block = fieldCopy[index(row, col)] if(block !== null) { var blockCount = getNumberOfConnectedBlocks(fieldCopy, row, col, block.type) if(blockCount >= 3) { gameOver = false break } } } } return gameOver } }
As we do not want to change the current game field when we check for connected blocks, we again operate on a copy of the field array. The function then works as follows:
We now want to react to the signal and display a window with the player score and a "play again"-button when a gameover occurs. Let's add a new file GameOverWindow.qml
.
qml/GameOverWindow.qml
:
import Felgo 4.0 import QtQuick 2.0 Item { id: gameOverWindow width: 232 height: 160 // hide when opacity = 0 visible: opacity > 0 // disable when opacity < 1 enabled: opacity == 1 // signal when new game button is clicked signal newGameClicked() Image { source: "../assets/GameOver.png" anchors.fill: parent } // display score Text { // set font font.family: gameFont.name font.pixelSize: 30 color: "#1a1a1a" text: scene.score // set position anchors.horizontalCenter: parent.horizontalCenter y: 72 } // play again button Text { // set font font.family: gameFont.name font.pixelSize: 15 color: "red" text: "play again" // set position anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom anchors.bottomMargin: 15 // signal click event MouseArea { anchors.fill: parent onClicked: gameOverWindow.newGameClicked() } // this animation sequence changes the color of text between red and orange infinitely SequentialAnimation on color { loops: Animation.Infinite PropertyAnimation { to: "#ff8800" duration: 1000 // 1 second for fade to orange } PropertyAnimation { to: "red" duration: 1000 // 1 second for fade to red } } } // shows the window function show() { gameOverWindow.opacity = 1 } // hides the window function hide() { gameOverWindow.opacity = 0 } }
The window consists of an image, the player score and a "play again"-button that is already animated by combining a SequentialAnimation with two
PropertyAnimations. In addition, the window also emits a signal when the button is clicked. We can open or close this window by calling the
show
- or hide
-function. Note that only the opacity
is set accordingly in these functions. We then use property
bindings like visible: opacity > 0
and enabled: opacity == 1
to:
We can add this window to our scene with just a few lines of code.
qml/Main.qml
:
import Felgo 4.0 import QtQuick 2.0 GameWindow { id: gameWindow // ... Scene { id: scene // ... GameArea { id: gameArea anchors.horizontalCenter: scene.horizontalCenter y: 20 blockSize: 30 onGameOver: gameOverWindow.show() } // configure gameover window GameOverWindow { id: gameOverWindow y: 90 opacity: 0 // by default the window is hidden anchors.horizontalCenter: scene.horizontalCenter onNewGameClicked: scene.startGame() } // initialize game function startGame() { gameOverWindow.hide() gameArea.initializeField() scene.score = 0 } } }
We added the GameOverWindow and implemented the handler onNewGameClicked
to start a new game. We then set the onGameOver
handler in our GameArea-object to show the window and hide it again when the
startGame
-function is called.
Hit play and enjoy your fully playable Juicy Squash game!
The game still feels a little bit stiff. We should use animations to fade out the blocks when removing them and actually let the fruits fall down from above. Let's start with preparing the animations in our
Block.qml
.
qml/Block.qml
:
EntityBase { id: block entityType: "block" // hide block if outside of game area visible: y >= 0 // ... // fade out block before removal NumberAnimation { id: fadeOutAnimation target: block property: "opacity" duration: 100 from: 1.0 to: 0 // remove block after fade out is finished onStopped: { entityManager.removeEntityById(block.entityId) } } // animation to let blocks fall down NumberAnimation { id: fallDownAnimation target: block property: "y" } // timer to wait for other blocks to fade out Timer { id: fallDownTimer interval: fadeOutAnimation.duration repeat: false running: false onTriggered: { fallDownAnimation.start() } } // start fade out / removal of block function remove() { fadeOutAnimation.start() } // trigger fall down of block function fallDown(distance) { // complete previous fall before starting a new one fallDownAnimation.complete() // move with 100 ms per block // e.g. moving down 2 blocks takes 200 ms fallDownAnimation.duration = 100 * distance fallDownAnimation.to = block.y + distance * block.height // wait for removal of other blocks before falling down fallDownTimer.start() } }
Note that we set visible: y >= 0
to hide the freshly created fruits that will be placed outside of the game area. They will automatically show when their animation moves them into the game area. We then use
two NumberAnimations to realize the fade-out and the movement of the fruits. Before any block starts moving, it should wait for the fade-out of other
blocks in the game. To achieve this we use a Timer and set the fade-out duration as its interval. We are going to start the movement after that time has passed.
We can now use the fallDown
- and remove
-functions to animate the fruits:
remove
-function fades out the block and removes the entity from the game when the animation is finished.fallDown
-function waits some time until the removing of other blocks in the grid is finished and then moves the block down by a certain distance.Let's use them within the removeConnectedBlocks
- and moveBlockToBottom
-functions of our game area.
qml/GameArea.qml
:
Item { id: gameArea // ... // remove previously marked blocks function removeConnectedBlocks(fieldCopy) { // search for blocks to remove for(var i = 0; i < fieldCopy.length; i++) { if(fieldCopy[i] === null) { // remove block from field var block = gameArea.field[i] if(block !== null) { gameArea.field[i] = null block.remove() } } } } // move remaining blocks to the bottom and fill up columns with new blocks function moveBlocksToBottom() { // check all columns for empty fields for(var col = 0; col < columns; col++) { // start at the bottom of the field for(var row = rows - 1; row >= 0; row--) { // find empty field if(gameArea.field[index(row, col)] === null) { // find block to move down var moveBlock = null for(var moveRow = row - 1; moveRow >= 0; moveRow--) { moveBlock = gameArea.field[index(moveRow,col)] if(moveBlock !== null) { gameArea.field[index(moveRow,col)] = null gameArea.field[index(row, col)] = moveBlock moveBlock.row = row moveBlock.fallDown(row - moveRow) break } } // if no block found, fill whole column up with new blocks if(moveBlock === null) { var distance = row + 1 for(var newRow = row; newRow >= 0; newRow--) { var newBlock = createBlock(newRow - distance, col) gameArea.field[index(newRow, col)] = newBlock newBlock.row = newRow newBlock.fallDown(distance) } // column already filled up, no need to check higher rows again break } } } // end check rows starting from the bottom } // end check columns for empty fields } // ... }
The removeConnectedBlocks
-function now uses the new remove
-function of the block. In the previous version of the function, we directly removed it with the entity manager.
When we move a block to the bottom or create new blocks, we simply use the fallDown
-function instead of changing the y
-position of the blocks. Note that when we create new blocks, we now place them
outside of the game area by setting newRow - distance
as the initial grid position at the createBlock
-call.
We can watch the fruits fall down now, pretty cool! But at this point it would be possible to click and remove blocks while they are fading out or moving down. We want to ignore these signals to avoid unwanted behavior that may occur when we try to change the field while the previous changes aren't completed yet. For this purpose we will add a function to check if the field is ready.
qml/GameArea.qml
:
Item { id: gameArea // ... // handle user clicks function handleClick(row, column, type) { if(!isFieldReadyForNewBlockRemoval()) return // ... } // ... // returns true if all animations are finished and new blocks may be removed function isFieldReadyForNewBlockRemoval() { // check if top row has empty spots or blocks not fully within game area for(var col = 0; col < columns; col++) { var block = field[index(0, col)] if(block === null || block.y < 0) return false } // field is ready return true } }
If the field is not ready yet, we ignore all clicked-events in our handler function. The fieldIsReady()
-function only returns true when every spot in the first row of the grid is filled with a block, that has
finished moving into the game area.
That's all we need to ignore clicks while we fade out and move our fruits. To complete the animations for our game, we also let the gameover-window fade in and out by defining a Behavior on the opacity
property.
qml/GameOverWindow.qml
:
Item { id: gameOverWindow // ... // fade in/out animation Behavior on opacity { NumberAnimation { duration: 400 } } // ... }
This is as far as we go in terms of animations.
When we play the game now, it may be over real quick because there are so many different fruit types on the field initially. This makes it very likely to run out of matches pretty soon. We can counter that by starting with fewer fruits and add other types later during the game. We just need to introduce some additional properties in our game area and use them accordingly.
qml/GameArea.qml
:
Item { id: gameArea // ... // properties for increasing game difficulty property int maxTypes property int clicks // ... function initializeField() { // reset difficulty gameArea.clicks = 0 gameArea.maxTypes = 3 // ... } // create a new block at specific position function createBlock(row, column) { // configure block var entityProperties = { width: blockSize, height: blockSize, x: column * blockSize, y: row * blockSize, type: Math.floor(Math.random() * gameArea.maxTypes), // random type row: row, column: column } // ... } // handle user clicks function handleClick(row, column, type) { // ... if(blockCount >= 3) { // ... // increase difficulty every 10 clicks until maxTypes == 5 gameArea.clicks++ if((gameArea.maxTypes < 5) && (gameArea.clicks % 10 == 0)) gameArea.maxTypes++ } } // ... }
We define two new properties maxTypes
and clicks
to hold the number of currently available types and the number of successful moves the player made. We reset these properties every time we
initialize the field. The random type of a new block is then based on the maxTypes
property. After each successful player move, we increase the click counter and based on that we may also increase the maximum
number of types. We decided to start with only three fruit types and add a new type after every 10th successful move, until we reach our maximum number of types.
Congratulations, you just completed this tutorial for the match-3 game Juicy Squash! Of course, you are welcome to include your own ideas, add some twists to the game or create your own match-3 game based on this one.
The game is still lacking some major features, you can:
You can also try adding blocks with special powers or multiple levels with different goals to make the game more interesting.
Also visit Felgo Games Examples and Demos to gain more information about game creation with Felgo and to see the source code of existing apps in the app stores.