Skip to content

Instantly share code, notes, and snippets.

@straker
Last active October 20, 2023 13:13
Show Gist options
  • Save straker/afc5bedc7f4b4bc65ba8b05c435f6d32 to your computer and use it in GitHub Desktop.
Save straker/afc5bedc7f4b4bc65ba8b05c435f6d32 to your computer and use it in GitHub Desktop.
Basic Bust-a-Move / Puzzle Bobble / Bubble Shooter HTML and JavaScript Game

Basic Bust-a-Move / Puzzle Bobble / Bubble Shooter HTML and JavaScript Game

This is a basic implementation of the game Bust-a-Move / Puzzle Bobble / Bubble Shooter, but it's missing a few things intentionally and they're left as further exploration for the reader.

Further Exploration

  • More levels
    • Add more levels and have the next level start once the last one is finished
  • Next Piece Preview
    • Show the next bubble that will be shot
  • Ceiling Drop
    • Every so often, the ceiling will drop down 1 grid space and move all the bubbles down the screen with it
  • Mobile and touchscreen support

Important note: I will answer questions about the code but will not add more features or answer questions about adding more features. This series is meant to give a basic outline of the game but nothing more.

License

(CC0 1.0 Universal) You're free to use this game and code in any project, personal or commercial. There's no need to ask permission before using these. Giving attribution is not required, but appreciated.

Other Basic Games

Support

Basic HTML Games are made possible by users like you. When you become a Patron, you get access to behind the scenes development logs, the ability to vote on which games I work on next, and early access to the next Basic HTML Game.

Top Patrons

  • Karar Al-Remahy
  • UnbrandedTech
  • Innkeeper Games
  • Nezteb
<!DOCTYPE html>
<html>
<head>
<title>Basic Bust-a-Move / Puzzle Bobble / Bubble Shooter HTML Game</title>
<meta charset="UTF-8">
<style>
html, body {
height: 100%;
margin: 0;
}
body {
background: black;
display: flex;
align-items: center;
justify-content: center;
}
</style>
</head>
<body>
<canvas width="271" height="392" id="game"></canvas>
<script>
const canvas = document.getElementById('game');
const context = canvas.getContext('2d');
// puzzle bubble is played on a hex grid. instead of doing complicated
// math of working with a hex grid, we can just fill the screen with
// bubbles in their correct positions. each bubble will start inactive,
// meaning we pretend the bubble isn't there (don't draw it or count
// it for collision). when the bubble we shoot collides with a wall
// or another active bubble, we just find the closest inactive bubble
// and make it active with the same color as the shot bubble. this
// gives the illusion of the bubble snapping to a grid
const grid = 32;
// each even row is 8 bubbles long and each odd row is 7 bubbles long.
// the level consists of 4 rows of bubbles of 4 colors: red, orange,
// green, and yellow
const level1 = [
['R','R','Y','Y','B','B','G','G'],
['R','R','Y','Y','B','B','G'],
['B','B','G','G','R','R','Y','Y'],
['B','G','G','R','R','Y','Y']
];
// create a mapping between color short code (R, G, B, Y) and color name
const colorMap = {
'R': 'red',
'G': 'green',
'B': 'blue',
'Y': 'yellow'
};
const colors = Object.values(colorMap);
// use a 1px gap between each bubble
const bubbleGap = 1;
// the size of the outer walls for the game
const wallSize = 4;
const bubbles = [];
let particles = [];
// helper function to convert deg to radians
function degToRad(deg) {
return (deg * Math.PI) / 180;
}
// rotate a point by an angle
function rotatePoint(x, y, angle) {
let sin = Math.sin(angle);
let cos = Math.cos(angle);
return {
x: x * cos - y * sin,
y: x * sin + y * cos
};
}
// get a random integer between the range of [min,max]
// @see https://stackoverflow.com/a/1527820/2124254
function getRandomInt(min, max) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
}
// get the distance between two points
function getDistance(obj1, obj2) {
const distX = obj1.x - obj2.x;
const distY = obj1.y - obj2.y;
return Math.sqrt(distX * distX + distY * distY);
}
// check for collision between two circles
function collides(obj1, obj2) {
return getDistance(obj1, obj2) < obj1.radius + obj2.radius;
}
// find the closest bubbles that collide with the object
function getClosestBubble(obj, activeState = false) {
const closestBubbles = bubbles
.filter(bubble => bubble.active == activeState && collides(obj, bubble));
if (!closestBubbles.length) {
return;
}
return closestBubbles
// turn the array of bubbles into an array of distances
.map(bubble => {
return {
distance: getDistance(obj, bubble),
bubble
}
})
.sort((a, b) => a.distance - b.distance)[0].bubble;
}
// create the bubble grid bubble. passing a color will create
// an active bubble
function createBubble(x, y, color) {
const row = Math.floor(y / grid);
const col = Math.floor(x / grid);
// bubbles on odd rows need to start half-way on the grid
const startX = row % 2 === 0 ? 0 : 0.5 * grid;
// because we are drawing circles we need the x/y position
// to be the center of the circle instead of the top-left
// corner like you would for a square
const center = grid / 2;
bubbles.push({
x: wallSize + (grid + bubbleGap) * col + startX + center,
// the bubbles are closer on the y axis so we subtract 4 on every
// row
y: wallSize + (grid + bubbleGap - 4) * row + center,
radius: grid / 2,
color: color,
active: color ? true : false
});
}
// get all bubbles that touch the passed in bubble
function getNeighbors(bubble) {
const neighbors = [];
// check each of the 6 directions by "moving" the bubble by a full
// grid in each of the 6 directions (60 degree intervals)
// @see https://www.redblobgames.com/grids/hexagons/#angles
const dirs = [
// right
rotatePoint(grid, 0, 0),
// up-right
rotatePoint(grid, 0, degToRad(60)),
// up-left
rotatePoint(grid, 0, degToRad(120)),
// left
rotatePoint(grid, 0, degToRad(180)),
// down-left
rotatePoint(grid, 0, degToRad(240)),
// down-right
rotatePoint(grid, 0, degToRad(300))
];
for (let i = 0; i < dirs.length; i++) {
const dir = dirs[i];
const newBubble = {
x: bubble.x + dir.x,
y: bubble.y + dir.y,
radius: bubble.radius
};
const neighbor = getClosestBubble(newBubble, true);
if (neighbor && neighbor !== bubble && !neighbors.includes(neighbor)) {
neighbors.push(neighbor);
}
}
return neighbors;
}
// remove bubbles that create a match of 3 colors
function removeMatch(targetBubble) {
const matches = [targetBubble];
bubbles.forEach(bubble => bubble.processed = false);
targetBubble.processed = true;
// loop over the neighbors of matching colors for more matches
let neighbors = getNeighbors(targetBubble);
for (let i = 0; i < neighbors.length; i++) {
let neighbor = neighbors[i];
if (!neighbor.processed) {
neighbor.processed = true;
if (neighbor.color === targetBubble.color) {
matches.push(neighbor);
neighbors = neighbors.concat(getNeighbors(neighbor));
}
}
}
if (matches.length >= 3) {
matches.forEach(bubble => {
bubble.active = false;
});
}
}
// make any floating bubbles (bubbles that don't have a bubble chain
// that touch the ceiling) drop down the screen
function dropFloatingBubbles() {
const activeBubbles = bubbles.filter(bubble => bubble.active);
activeBubbles.forEach(bubble => bubble.processed = false);
// start at the bubbles that touch the ceiling
let neighbors = activeBubbles
.filter(bubble => bubble.y - grid <= wallSize);
// process all bubbles that form a chain with the ceiling bubbles
for (let i = 0; i < neighbors.length; i++) {
let neighbor = neighbors[i];
if (!neighbor.processed) {
neighbor.processed = true;
neighbors = neighbors.concat(getNeighbors(neighbor));
}
}
// any bubble that is not processed doesn't touch the ceiling
activeBubbles
.filter(bubble => !bubble.processed)
.forEach(bubble => {
bubble.active = false;
// create a particle bubble that falls down the screen
particles.push({
x: bubble.x,
y: bubble.y,
color: bubble.color,
radius: bubble.radius,
active: true
});
});
}
// fill the grid with inactive bubbles
for (let row = 0; row < 10; row++) {
for (let col = 0; col < (row % 2 === 0 ? 8 : 7); col++) {
// if the level has a bubble at the location, create an active
// bubble rather than an inactive one
const color = level1[row]?.[col];
createBubble(col * grid, row * grid, colorMap[color]);
}
}
const curBubblePos = {
// place the current bubble horizontally in the middle of the screen
x: canvas.width / 2,
y: canvas.height - grid * 1.5
};
const curBubble = {
x: curBubblePos.x,
y: curBubblePos.y,
color: 'red',
radius: grid / 2, // a circles radius is half the width (diameter)
// how fast the bubble should go in either the x or y direction
speed: 8,
// bubble velocity
dx: 0,
dy: 0
};
// angle (in radians) of the shooting arrow
let shootDeg = 0;
// min/max angle (in radians) of the shooting arrow
const minDeg = degToRad(-60);
const maxDeg = degToRad(60);
// the direction of movement for the arrow (-1 = left, 1 = right)
let shootDir = 0;
// reset the bubble to shoot to the bottom of the screen
function getNewBubble() {
curBubble.x = curBubblePos.x;
curBubble.y = curBubblePos.y;
curBubble.dx = curBubble.dy = 0;
const randInt = getRandomInt(0, colors.length - 1);
curBubble.color = colors[randInt];
}
// handle collision between the current bubble and another bubble
function handleCollision(bubble) {
bubble.color = curBubble.color;
bubble.active = true;
getNewBubble();
removeMatch(bubble);
dropFloatingBubbles();
}
// game loop
function loop() {
requestAnimationFrame(loop);
context.clearRect(0,0,canvas.width,canvas.height);
// move the shooting arrow
shootDeg = shootDeg + degToRad(2) * shootDir;
// prevent shooting arrow from going below/above min/max
if (shootDeg < minDeg) {
shootDeg = minDeg;
}
else if (shootDeg > maxDeg) {
shootDeg = maxDeg
}
// move current bubble by it's velocity
curBubble.x += curBubble.dx;
curBubble.y += curBubble.dy;
// prevent bubble from going through walls by changing its velocity
if (curBubble.x - grid / 2 < wallSize) {
curBubble.x = wallSize + grid / 2;
curBubble.dx *= -1;
}
else if (curBubble.x + grid / 2 > canvas.width - wallSize) {
curBubble.x = canvas.width - wallSize - grid / 2;
curBubble.dx *= -1;
}
// check to see if bubble collides with the top wall
if (curBubble.y - grid / 2 < wallSize) {
// make the closest inactive bubble active
const closestBubble = getClosestBubble(curBubble);
handleCollision(closestBubble);
}
// check to see if bubble collides with another bubble
for (let i = 0; i < bubbles.length; i++) {
const bubble = bubbles[i];
if (bubble.active && collides(curBubble, bubble)) {
const closestBubble = getClosestBubble(curBubble);
if (!closestBubble) {
window.alert('Game Over');
window.location.reload();
}
if (closestBubble) {
handleCollision(closestBubble);
}
}
}
// move bubble particles
particles.forEach(particle => {
particle.y += 8;
});
// remove particles that went off the screen
particles = particles.filter(particles => particles.y < canvas.height - grid / 2);
// draw walls
context.fillStyle = 'lightgrey';
context.fillRect(0, 0, canvas.width, wallSize);
context.fillRect(0, 0, wallSize, canvas.height);
context.fillRect(canvas.width - wallSize, 0, wallSize, canvas.height);
// draw bubbles and particles
bubbles.concat(particles).forEach(bubble => {
if (!bubble.active) return;
context.fillStyle = bubble.color;
// draw a circle
context.beginPath();
context.arc(bubble.x, bubble.y, bubble.radius, 0, 2 * Math.PI);
context.fill();
});
// draw fire arrow. since we're rotating the canvas we need to save
// the state and restore it when we're done
context.save();
// move to the center of the rotation (the middle of the bubble)
context.translate(curBubblePos.x, curBubblePos.y);
context.rotate(shootDeg);
// move to the top-left corner of or fire arrow
context.translate(0, -grid / 2 * 4.5);
// draw arrow ↑
context.strokeStyle = 'white';
context.lineWidth = 2;
context.beginPath();
context.moveTo(0, 0);
context.lineTo(0, grid * 2);
context.moveTo(0, 0);
context.lineTo(-10, grid * 0.4);
context.moveTo(0, 0);
context.lineTo(10, grid * 0.4);
context.stroke();
context.restore();
// draw current bubble
context.fillStyle = curBubble.color;
context.beginPath();
context.arc(curBubble.x, curBubble.y, curBubble.radius, 0, 2 * Math.PI);
context.fill();
}
// listen for keyboard events to move the fire arrow
document.addEventListener('keydown', (e) => {
if (e.code === 'ArrowLeft') {
shootDir = -1;
}
else if (e.code === 'ArrowRight') {
shootDir = 1;
}
// if the current bubble is not moving we can launch it
if (e.code === 'Space' && curBubble.dx === 0 && curBubble.dy === 0) {
// convert an angle to x/y
curBubble.dx = Math.sin(shootDeg) * curBubble.speed;
curBubble.dy = -Math.cos(shootDeg) * curBubble.speed;
}
});
// listen for keyboard events to stop moving the fire arrow if key is
// released
document.addEventListener('keyup', (e) => {
if (
// only reset shoot dir if the released key is also the current
// direction of movement. otherwise if you press down both arrow
// keys at the same time and then release one of them, the arrow
// stops moving even though you are still pressing a key
(e.code === 'ArrowLeft' && shootDir === -1) ||
(e.code === 'ArrowRight' && shootDir === 1)
) {
shootDir = 0;
}
});
// start the game
requestAnimationFrame(loop);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment