Creating A Bubble Shooter Game Tutorial With HTML5
In this tutorial we are creating a bubble shooter game with HTML5 and JavaScript. In a bubble shooter game, the player shoots colored bubbles into a level that consists of other colored bubbles. When the bubble of the player collides with the bubbles in the level and a cluster of three or more bubbles with the same color is formed, the cluster is removed and the player is rewarded points. After removing clusters, some bubbles might be floating in the air. These clusters will be removed as well. Finally, the player is given the next bubble and can shoot again. If any of the bubbles reaches the bottom of the level, the player has lost the game. The game uses a variation on the Match-3 mechanic, the clustering of colored tiles, that we have seen in my article How To Make A Match-3 Game With HTML5 Canvas.
This tutorial explains how to build the game from start to finish and explains the algorithms with code examples. At the end of this tutorial, you can play the game that was created.

Bubble Shooter Game Tutorial With HTML5 And JavaScript
Click here to go directly to the end of this article and play the game.
Bubble Shooter, Bust-A-Move and Puzzle Bobble
There are a lot of variations in the bubble shooter genre. Some games are level based, while others have a more survival style of gameplay. One of the first games in this genre, is Bust-A-Move, also known as Puzzle Bobble. The main game consists of multiple levels. A player has to clear the level of bubbles to move to the next level. After some time during the game, the ceiling of the level falls down, making it harder to win the level.

Bust-A-Move / Puzzle Bobble for the Super Nintendo
One of the most popular online casual games is simply called Bubble Shooter. It is a survival style game. The player has to clear the level, but after a number of turns without creating clusters, new bubbles are added to the top of the level. The game is obviously inspired by Bust-A-Move, but it focuses on the survival style gameplay.

Bubble Shooter
If we want to create a bubble shooter game of our own, we have to look at the elements that are needed in the game. First, there is a level of bubbles in a grid-like structure. This structure is not a regular grid. The player needs to be able to shoot bubbles in a certain direction. Bubbles need to collide with other bubbles and clusters of bubbles need to be detected and removed. The game we are building in this tutorial will have a single level, using the survival style gameplay. After a number of turns without forming clusters, a new row of bubbles is added to the top of the level. This means that the existing bubbles are moved towards the bottom of the level. If any of the bubbles reaches the bottom of the level, the game is over. The next chapters explain how to implement the game.
Hexagonal Grid From A 2d Array
The playing field of a level in a bubble shooter game consists of a grid of bubbles. Every bubble is connected to 6 other bubbles, therefore the bubbles should be organized in a hexagonal grid. We can implement the hexagonal grid using a regular 2d array, by visually shifting every other row in the 2d grid. The following image shows how we can transform a regular 2d array into a hexagonal grid. The bubbles on the left each have 4 direct neighbors, while the bubbles on the right have 6 direct neighbors. The only thing we did was visually shift every other row in the 2d array to the right, by half the width of a bubble. We have to compensate for this shift in our algorithms, as we will see later in this tutorial.

Hexagonal Grid From A 2d Array
The bubbles in our bubble shooter game are also called tiles. Let’s convert a 2d tile index into a hexagonal grid screen position. Assume we have a tile that has a width of tilewidth and a height of tileheight and assume the tile exists in a 2d array tilearray. The tile sits at a column and row of the tilearray like tilearray[column][row]. We can convert the tile to a screen position as follows.
1 2 3 4 5 6 7 8 9 10 11 12 | // Get the tile coordinate function getTileCoordinate(column, row) { var tilex = column * tilewidth; // X offset for odd rows if (row % 2) { tilex += tilewidth/2; } var tiley = row * tileheight; return { tilex: tilex, tiley: tiley }; } |
We can use the getTileCoordinate function to find out our tile positions and render them to the screen. The the renderTiles function below shows an example of how we can render our tiles from the 2d tile array to the screen.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | // Render tiles function renderTiles() { // Top to bottom for (var j=0; j<rows; j++) { for (var i=0; i<columns; i++) { // Get the tile var tile = tilearray[i][j]; // Calculate the tile coordinates var coord = getTileCoordinate(i, j); // Draw the tile drawTile(coord.tilex, coord.tiley, tile.type); } } } |
By arranging the bubbles in a hexagonal pattern, we can see that there are visual gaps between the bubbles in the vertical direction. We can remove these gaps by substituting the tileheight variable inside the getTileCoordinate function with a rowheight variable. If we set the value of the rowheight variable to be smaller than the tileheight variable, we decrease the visual gap between the bubbles. Furthermore, we can decide if we want to shift the even rows or the odd rows by adding a rowoffset variable. This change comes in handy when we have to add a new row of bubbles to the top of the level, while preserving the bubble layout. The getTileCoordinate function becomes:
1 2 3 4 5 6 7 8 9 10 11 12 | // Get the tile coordinate function getTileCoordinate(column, row) { var tilex = column * tilewidth; // X offset for odd or even rows if ((row + rowoffset) % 2) { tilex += tilewidth/2; } var tiley = row * rowheight; return { tilex: tilex, tiley: tiley }; } |
Snapping Bubbles To The Hexagonal Grid
We have seen how to calculate the screen position of a specific bubble index from a 2d tile array in the previous chapter. Now we have to do the reverse. We have to calculate the 2d tile index from a given screen coordinate. If we know how to do this, we can shoot bubbles into the level and snap them to our hexagonal grid.
The getGridPosition function below assumes that the x and y position is the center position of a bubble. It divides the y by the rowheight to get the row of the bubble in the 2d array. The column, gridx, is calculated using a similar method. Of course we have to compensate for the horizontal shift if it applies to the current row.
1 2 3 4 5 6 7 8 9 10 11 12 13 | // Get the closest grid position function getGridPosition(x, y) { var gridy = Math.floor(y / rowheight); // Check for offset var xoffset = 0; if ((gridy + rowoffset) % 2) { xoffset = tilewidth / 2; } var gridx = Math.floor((x - xoffset) / tilewidth); return { x: gridx, y: gridy }; } |
The function returns an object with the closest array index of the bubble position, where x and y can be used as indices for the tile array as follows.
1 2 3 4 5 6 7 8 9 | // Get the grid position var centerx = bubble.x + tilewidth/2; var centery = bubble.y + tileheight/2; var gridpos = getGridPosition(centerx, centery); // Access the tilearray at the calculated index if (tilearray[gridpos.x][gridpos.y].type != -1) { // (...) } |
Aiming With The Mouse
In our game, we want to shoot bubbles from the bottom of the screen in the direction of our mouse pointer. The image below explains the situation. The position of the player is at the bottom of the screen at location (player.x, player.y). The player is represented by the white bubble. Our mouse points somewhere above the player at location (mouse.x, mouse.y). We want to calculate the angle between the line that goes through the center of the bubble and the mouse position, and the horizontal line that goes through the center of the bubble. We call this angle the mouseangle.

Aiming With The Mouse Using Atan2
We can calculate the mouseangle by using the Math.atan2(y, x) function. Note the order of the y and x parameters. The atan2 function returns the angle between the specified (x, y) location and the positive x-axis. The result of this function is an angle between -PI and PI radians, that is -180 and 180 degrees. We want to get an angle in the range of 0 to 360 degrees, so we have to do some conversions. The code below shows how to calculate the mouse angle. My tutorial How To Make An HTML5 Canvas Game explains how to implement the onMouseMove event handler and the getMousePos function.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | // Convert radians to degrees function radToDeg(angle) { return angle * (180 / Math.PI); } // On mouse movement function onMouseMove(e) { // Get the mouse position var pos = getMousePos(canvas, e); // Get the mouse angle var mouseangle = radToDeg(Math.atan2((player.y+tileheight/2) - pos.y, pos.x - (player.x+tilewidth/2))); // Convert range to 0, 360 degrees if (mouseangle < 0) { mouseangle = 180 + (180 + mouseangle); } // (...) // Set the player angle player.angle = mouseangle; } |
We want to restrict the mouseangle to fall within a certain range, because we want the player to shoot upward. We can achieve this by adding the following code to the onMouseMove event handler.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | // Restrict angle to 8, 172 degrees var lbound = 8; var ubound = 172; if (mouseangle > 90 && mouseangle < 270) { // Left if (mouseangle > ubound) { mouseangle = ubound; } } else { // Right if (mouseangle < lbound || mouseangle >= 270) { mouseangle = lbound; } } |
The angle of the mouse can be visualized by drawing a line from the center of the player in the direction of the mouse.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | // Convert degrees to radians function degToRad(angle) { return angle * (Math.PI / 180); } // Render the angle of the mouse function renderMouseAngle() { var centerx = player.x + tilewidth/2; var centery = player.y + tileheight/2; // Draw the angle context.lineWidth = 2; context.strokeStyle = "#0000ff"; context.beginPath(); context.moveTo(centerx, centery); context.lineTo(centerx + 1.5*tilewidth * Math.cos(degToRad(player.angle)), centery - 1.5*tileheight * Math.sin(degToRad(player.angle))); context.stroke(); } |
At the end of this tutorial, you can see the result of this code. The blue line points in the direction of the mouse. If the player presses a button, the bubble moves in the direction of the mouse.
Shooting The Bubble
After aiming with the mouse, we press the button to shoot the bubble in the direction of the mouse. While the bubble is moving, it can collide with the sides of the level. If it collides, we have to bounce the bubble back in the opposite direction. The bubble can hit the top of the level. It should remain there and stop moving. Finally, the bubble can collide with other bubbles. If the bubbles forms a match-3, a cluster of three or more bubbles, the cluster has to be removed. If no matches were created by the new bubble, the bubble should be added to the level at the location of the impact. For this tutorial, we are using a time-based movement system. This has the advantage of being easy to understand. A problem that can occur using this method, is that the movement of the bubble is too fast, when the framerate is not stable. This can result in missing collisions. To solve this, you could use a fixed timestep approach.
Let’s start with simply moving the bubble in the direction of mouseangle, the angle we calculated in the previous chapter. The time-based movement system has a delta time variable, which we call dt. This variable indicates the time difference between the current and the last frame, in seconds. To move the bubble in the direction of mouseangle, we multiply the speed of the bubble with the cosine or sine of the angle. Finally, we multiply the value by dt. The mouseangle assumes an x-axis that moves to the right and a y-axis that moves upward, while in an HTML5 canvas the y-axis moves downward. That’s why we have a -1 term in the following calculations. The bubble.angle contains the value from mouseangle.
1 2 3 4 5 6 7 8 | function stateShootBubble(dt) { // Move the bubble in the direction of the mouse bubble.x += dt * bubble.speed * Math.cos(degToRad(bubble.angle)); bubble.y += dt * bubble.speed * -1*Math.sin(degToRad(bubble.angle)); // (...) } |
While the bubble is moving, it can collide with the sides and top of the level. If it collides with the sides, we bounce the bubble in the opposite direction. We bounce the bubble by subtracting the current angle of the bubble from 180 degrees. If we detect a collision of the bubble with the top of the level, we add the bubble to the level using the snapBubble function which we explain below.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | // Handle left and right collisions with the level if (bubble.x <= level.x) { // Left edge bubble.angle = 180 - bubble.angle; bubble.x = level.x; } else if (bubble.x + tilewidth >= level.x + level.width) { // Right edge bubble.angle = 180 - bubble.angle; bubble.x = level.x + level.width - tilewidth; } // Collisions with the top of the level if (bubble.y <= level.y) { // Top collision bubble.y = level.y; snapBubble(); return; } |
Finally, we have to check if our bubble collides with other bubbles. We do this by looping over every bubble in the level and do a circle-circle collision test with our bubble. Our bubbles need to have a radius property that is used in the circle intersection test.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | // Collisions with other tiles for (var i=0; i<level.columns; i++) { for (var j=0; j<level.rows; j++) { var tile = level.tiles[i][j]; // Skip empty tiles if (tile.type < 0) { continue; } // Check for intersections var coord = getTileCoordinate(i, j); if (circleIntersection(bubble.x + tilewidth/2, bubble.y + tileheight/2, level.radius, coord.tilex + tilewidth/2, coord.tiley + tileheight/2, level.radius)) { // Intersection with a level bubble snapBubble(); return; } } } |
The circleIntersection function is explained below. It takes the x and y coordinate and a radius of two circles and returns if the circles intersect.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | // Check if two circles intersect function circleIntersection(x1, y1, r1, x2, y2, r2) { // Calculate the distance between the centers var dx = x1 - x2; var dy = y1 - y2; var len = Math.sqrt(dx * dx + dy * dy); if (len < r1 + r2) { // Circles intersect return true; } return false; } |
After we shoot a bubble into the level, the snapBubble function is used to add the current bubble to the grid and to handle the rest of the game state by finding and removing clusters, checking for game over, adding bubbles and handling the next bubble.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | // Snap bubble to the grid function snapBubble() { // Get the grid position var centerx = bubble.x + tilewidth/2; var centery = bubble.y + tileheight/2; var gridpos = getGridPosition(centerx, centery); // Make sure the grid position is valid // (...) // Add the tile to the grid level.tiles[gridpos.x][gridpos.y].type = bubble.tiletype; // Check for game over // Find and remove clusters // Add a row of bubbles after a number of turns // Next bubble // (...) } |
How To Find Clusters
When a bubble is added to the level, we have to find out if the bubble makes a match-3, a cluster of three or more bubbles of the same color. So we create a function findCluster that implements an algorithm that can find clusters in the array of bubbles at a specified location. We need to loop over our bubble array, starting from a specific starting location. We add this starting bubble to an array which we call toprocess. The toprocess array holds all of the bubbles that we still need to process. If we are done with processing a bubble, we mark it with a processed flag, so we don’t process bubble more than once. At each iteration, we take a bubble tile from the toprocess array and remove it from the array. We call this bubble the currenttile. We check if currenttile has the color we are looking for. If the bubble has the right type, we add it to a foundcluster array. We als add all of its unprocessed neighbors to the toprocess array, because they could be part of the cluster.
The code below shows an implementation of the algorithm to find clusters in the array of bubble tiles. The algorithm has some extra functionality to be able to find clusters of bubbles, even if they don’t have the same color. This allows us to find clusters of bubbles that are floating in the air, as we will see in the next chapter. This code is straight from the bubble shooter example game, so some variables are different than we have seen in the previous chapters.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 | // Find cluster at the specified tile location function findCluster(tx, ty, matchtype, reset, skipremoved) { // Reset the processed flags if (reset) { resetProcessed(); } // Get the target tile. Tile coord must be valid. var targettile = level.tiles[tx][ty]; // Initialize the toprocess array with the specified tile var toprocess = [targettile]; targettile.processed = true; var foundcluster = []; while (toprocess.length > 0) { // Pop the last element from the array var currenttile = toprocess.pop(); // Skip processed and empty tiles if (currenttile.type == -1) { continue; } // Skip tiles with the removed flag if (skipremoved && currenttile.removed) { continue; } // Check if current tile has the right type, if matchtype is true if (!matchtype || (currenttile.type == targettile.type)) { // Add current tile to the cluster foundcluster.push(currenttile); // Get the neighbors of the current tile var neighbors = getNeighbors(currenttile); // Check the type of each neighbor for (var i=0; i<neighbors.length; i++) { if (!neighbors[i].processed) { // Add the neighbor to the toprocess array toprocess.push(neighbors[i]); neighbors[i].processed = true; } } } } // Return the found cluster return foundcluster; } |
Here are the functions that are used in the algorithm. The neighbor offsets indicate the relative location of the 6 neighboring bubbles in the tiles array of a specified tile.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | // Reset the processed flags function resetProcessed() { for (var i=0; i<level.columns; i++) { for (var j=0; j<level.rows; j++) { level.tiles[i][j].processed = false; } } } // Neighbor offset table var neighborsoffsets = [[[1, 0], [0, 1], [-1, 1], [-1, 0], [-1, -1], [0, -1]], // Even row tiles [[1, 0], [1, 1], [0, 1], [-1, 0], [0, -1], [1, -1]]]; // Odd row tiles // Get the neighbors of the specified tile function getNeighbors(tile) { var tilerow = (tile.y + rowoffset) % 2; // Even or odd row var neighbors = []; // Get the neighbor offsets for the specified tile var n = neighborsoffsets[tilerow]; // Get the neighbors for (var i=0; i<n.length; i++) { // Neighbor coordinate var nx = tile.x + n[i][0]; var ny = tile.y + n[i][1]; // Make sure the tile is valid if (nx >= 0 && nx < level.columns && ny >= 0 && ny < level.rows) { neighbors.push(level.tiles[nx][ny]); } } return neighbors; } |
If we look at the snapBubble function from the previous chapter, we can use the findCluster function in the following way to find clusters at the (gridpos.x, gridpos.y) location of the added bubble.
1 2 | // Find clusters cluster = findCluster(gridpos.x, gridpos.y, true, true, false); |
Detecting Floating Bubbles
After we find the clusters and remove them, some bubbles might be floating in the air. We have to detect the floating bubbles and remove them as well. Luckily, we can use the findCluster function to find floating bubbles, if we change some of the parameters when calling the function.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | // Find floating clusters function findFloatingClusters() { // Reset the processed flags resetProcessed(); var foundclusters = []; // Check all tiles for (var i=0; i<level.columns; i++) { for (var j=0; j<level.rows; j++) { var tile = level.tiles[i][j]; if (!tile.processed) { // Find all attached tiles var foundcluster = findCluster(i, j, false, false, true); // There must be a tile in the cluster if (foundcluster.length <= 0) { continue; } // Check if the cluster is floating var floating = true; for (var k=0; k<foundcluster.length; k++) { if (foundcluster[k].y == 0) { // Tile is attached to the roof floating = false; break; } } if (floating) { // Found a floating cluster foundclusters.push(foundcluster); } } } } return foundclusters; } |
Adding Random Existing Colored Bubbles
The game we are creating implements the survival style gameplay. This means that after a number of turns, new bubbles should be added to the top of the level. All of the existing bubbles should be moved downward as a result. So assume we have a turncounter that counts the number of turns the player has taken. If it reaches a certain number, we reset the counter and add bubbles to the level using the addBubbles function.
The addBubbles function first moves every bubble one row downward, except for the last row. Afterwards, a new row of bubbles is created and copied to the first row of the tiles array. Only existing colors are considered, to allow for some game progression.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | function addBubbles() { // Move the rows downwards for (var i=0; i<level.columns; i++) { for (var j=0; j<level.rows-1; j++) { level.tiles[i][level.rows-1-j].type = level.tiles[i][level.rows-1-j-1].type; } } // Add a new row of bubbles at the top for (var i=0; i<level.columns; i++) { // Add random, existing, colors level.tiles[i][0].type = getExistingColor(); } } |
To find the existing colors, we look at all of the existing colors in the level and mark the color as found, if we encounter the color. Finally, we get a random color from the existing colors using the randRange function.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | // Number of different colors var bubblecolors = 7; // Get a random int between low and high, inclusive function randRange(low, high) { return Math.floor(low + Math.random()*(high-low+1)); } // Get a random existing color function getExistingColor() { existingcolors = findColors(); var bubbletype = 0; if (existingcolors.length > 0) { bubbletype = existingcolors[randRange(0, existingcolors.length-1)]; } return bubbletype; } // Find the remaining colors function findColors() { var foundcolors = []; var colortable = []; for (var i=0; i<bubblecolors; i++) { colortable.push(false); } // Check all tiles for (var i=0; i<level.columns; i++) { for (var j=0; j<level.rows; j++) { var tile = level.tiles[i][j]; if (tile.type >= 0) { if (!colortable[tile.type]) { colortable[tile.type] = true; foundcolors.push(tile.type); } } } } return foundcolors; } |
Check For Game Over
If any of the bubbles reaches the bottom of the level, the player loses and the game is over. We can implement this by reserving the last row of the tiles array. If any of the bubble tiles moves into the last row, the game is over. We can check if we are game over, by looping over every bubble in the last row of the tiles array and checking if a bubble exists.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | function checkGameOver() { // Check for game over for (var i=0; i<level.columns; i++) { // Check if there are bubbles in the bottom row if (level.tiles[i][level.rows-1].type != -1) { // Game over nextBubble(); setGameState(gamestates.gameover); return true; } } return false; } |
We now have all of the functionality we need to create a fully working bubble shooter game. Check out the live demo below or look at the full source code to see all of the details.
Bubble Shooter Example
Here is the bubble shooter game we created in this tutorial. The full source code is available on GitHub, licensed under GPLv3. The game can be expanded with better animations or a better scoring system. You could even implement a level system, instead of the survival style gameplay. Play the game by aiming with the mouse and clicking the mousebutton to shoot bubbles.