Skip to content

Instantly share code, notes, and snippets.

@straker
Last active October 11, 2025 23:41
Show Gist options
  • Select an option

  • Save straker/afc5bedc7f4b4bc65ba8b05c435f6d32 to your computer and use it in GitHub Desktop.

Select an option

Save straker/afc5bedc7f4b4bc65ba8b05c435f6d32 to your computer and use it in GitHub Desktop.

Revisions

  1. straker revised this gist Jul 14, 2023. 1 changed file with 1 addition and 0 deletions.
    1 change: 1 addition & 0 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -45,3 +45,4 @@ Basic HTML Games are made possible by users like you. When you become a [Patron]
    - Karar Al-Remahy
    - UnbrandedTech
    - Innkeeper Games
    - Nezteb
  2. straker revised this gist Mar 3, 2023. 1 changed file with 1 addition and 0 deletions.
    1 change: 1 addition & 0 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -34,6 +34,7 @@ This is a basic implementation of the game Bust-a-Move / Puzzle Bobble / Bubble
    - [Sokoban](https://gist.github.com/straker/2fddb507d4bb6bec54ea2fdb022d020c)
    - [Doodle Jump](https://gist.github.com/straker/b96a4a68bd6d79cf75a833d98a2b654f)
    - [Helicopter](https://gist.github.com/straker/0d25ae9d235f6a62f8287fd36a097043)
    - [Block Dude](https://gist.github.com/straker/df855f22e57576c80d6126aa5609654e)

    ## Support

  3. straker revised this gist Jan 27, 2023. 1 changed file with 2 additions and 1 deletion.
    3 changes: 2 additions & 1 deletion README.md
    Original file line number Diff line number Diff line change
    @@ -42,4 +42,5 @@ Basic HTML Games are made possible by users like you. When you become a [Patron]
    ### Top Patrons

    - Karar Al-Remahy
    - UnbrandedTech
    - UnbrandedTech
    - Innkeeper Games
  4. straker revised this gist Jun 22, 2022. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion README.md
    Original file line number Diff line number Diff line change
    @@ -33,7 +33,7 @@ This is a basic implementation of the game Bust-a-Move / Puzzle Bobble / Bubble
    - [Missile Command](https://gist.github.com/straker/afc4e2a30b6df772a5f9f6ef01751d41)
    - [Sokoban](https://gist.github.com/straker/2fddb507d4bb6bec54ea2fdb022d020c)
    - [Doodle Jump](https://gist.github.com/straker/b96a4a68bd6d79cf75a833d98a2b654f)
    - [Helicopter](https://gist.github.com/straker/straker/0d25ae9d235f6a62f8287fd36a097043)
    - [Helicopter](https://gist.github.com/straker/0d25ae9d235f6a62f8287fd36a097043)

    ## Support

  5. straker revised this gist Jun 21, 2022. 1 changed file with 1 addition and 0 deletions.
    1 change: 1 addition & 0 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -33,6 +33,7 @@ This is a basic implementation of the game Bust-a-Move / Puzzle Bobble / Bubble
    - [Missile Command](https://gist.github.com/straker/afc4e2a30b6df772a5f9f6ef01751d41)
    - [Sokoban](https://gist.github.com/straker/2fddb507d4bb6bec54ea2fdb022d020c)
    - [Doodle Jump](https://gist.github.com/straker/b96a4a68bd6d79cf75a833d98a2b654f)
    - [Helicopter](https://gist.github.com/straker/straker/0d25ae9d235f6a62f8287fd36a097043)

    ## Support

  6. straker revised this gist Apr 4, 2022. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion README.md
    Original file line number Diff line number Diff line change
    @@ -1,4 +1,4 @@
    # Basic Bust-a-Move / Puzzle Bobble / Bubble Shooter HTML 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.

  7. straker revised this gist Apr 3, 2022. No changes.
  8. straker revised this gist Apr 3, 2022. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion README.md
    Original file line number Diff line number Diff line change
    @@ -2,7 +2,7 @@

    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.

    <img width="276" height="400" alt="" src="ttps://user-images.githubusercontent.com/2433219/161411816-910d3717-987c-44ef-8b61-f49127373ad5.png">
    <img width="276" height="400" alt="" src="https://user-images.githubusercontent.com/2433219/161411816-910d3717-987c-44ef-8b61-f49127373ad5.png">

    ## Further Exploration

  9. straker revised this gist Apr 3, 2022. No changes.
  10. straker revised this gist Apr 3, 2022. 2 changed files with 465 additions and 2 deletions.
    11 changes: 9 additions & 2 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -1,11 +1,17 @@
    # Basic Sokban HTML Game
    # Basic Bust-a-Move / Puzzle Bobble / Bubble Shooter HTML Game

    This is a basic implementation of the game Puzzle Bobble / Bubble Shooter, but it's missing a few things intentionally and they're left as further exploration for the reader.
    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.

    <img width="276" height="400" alt="" src="ttps://user-images.githubusercontent.com/2433219/161411816-910d3717-987c-44ef-8b61-f49127373ad5.png">

    ## 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
    - Allow the game to be scaled down to a phone size. See https://codepen.io/straker/pen/VazMaL
    - Support [touch controls](https://developer.mozilla.org/en-US/docs/Web/API/Touch_events)
    @@ -25,6 +31,7 @@ This is a basic implementation of the game Puzzle Bobble / Bubble Shooter, but i
    - [Bomberman](https://gist.github.com/straker/769fb461e066147ea16ac2cb9463beae)
    - [Frogger](https://gist.github.com/straker/82a4368849cbd441b05bd6a044f2b2d3)
    - [Missile Command](https://gist.github.com/straker/afc4e2a30b6df772a5f9f6ef01751d41)
    - [Sokoban](https://gist.github.com/straker/2fddb507d4bb6bec54ea2fdb022d020c)
    - [Doodle Jump](https://gist.github.com/straker/b96a4a68bd6d79cf75a833d98a2b654f)

    ## Support
    456 changes: 456 additions & 0 deletions puzzle-bobble.html
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,456 @@
    <!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>
  11. straker created this gist Mar 31, 2022.
    37 changes: 37 additions & 0 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,37 @@
    # Basic Sokban HTML Game

    This is a basic implementation of the game 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
    - Mobile and touchscreen support
    - Allow the game to be scaled down to a phone size. See https://codepen.io/straker/pen/VazMaL
    - Support [touch controls](https://developer.mozilla.org/en-US/docs/Web/API/Touch_events)

    **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

    - [Snake](https://gist.github.com/straker/ff00b4b49669ad3dec890306d348adc4)
    - [Pong](https://gist.github.com/straker/81b59eecf70da93af396f963596dfdc5)
    - [Breakout](https://gist.github.com/straker/98a2aed6a7686d26c04810f08bfaf66b)
    - [Tetris](https://gist.github.com/straker/3c98304f8a6a9174efd8292800891ea1)
    - [Bomberman](https://gist.github.com/straker/769fb461e066147ea16ac2cb9463beae)
    - [Frogger](https://gist.github.com/straker/82a4368849cbd441b05bd6a044f2b2d3)
    - [Missile Command](https://gist.github.com/straker/afc4e2a30b6df772a5f9f6ef01751d41)
    - [Doodle Jump](https://gist.github.com/straker/b96a4a68bd6d79cf75a833d98a2b654f)

    ## Support

    Basic HTML Games are made possible by users like you. When you become a [Patron](https://www.patreon.com/straker), 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