A few days ago someone posted a clever bubble-wrap “mini-game” on Discord.

It was posted on a private server and my spouse ask me to forward that to them, but it worked with Discord’s spoiler-tag feature and if I waned to copy-paste it, the content I get was the content without spoiler-tags.

No problem, I’m an engineer. In seconds I crafted a quick python script that’s able to generate an NxN bubble-wrap for Discord.

print("Anyone want some bubble wrap?")
size = 8
print("\n".join(["||pop||" * size] * size))

The first line is important because The first line is always contains the display name of the user who sent the message so it would look terrible without the initial “spam text”.

We can do more

A few minutes later someone wrote

Popping this feels like playing Minesweeper without hitting a bomb xD

And, that’s it, it was something, something interesting. What do we need?

First of all, we need math and random, because we want to place mines on random positions and if we want to determine the number of mines based on a given percentage of the grid, we have to use one of them: round, ceil, floor.

Obviously, we need a function that can wrap a string in spoiler-tags (on Discord it’s ||hide me||).

import math
import random

def hide(content):
    return f'||{content}||'

Next thing to build is the grid itself and place some mines on in.

BOMB_MARK = 'X'
size = 8
number_of_bombs = math.ceil(size*size * 0.2)
grid = " " * size * size

bombs = set()
while len(bombs) < number_of_bombs:
    bombs.add(random.randint(0, size*size - 1))

for i in bombs:
    grid = grid[:i] + BOMB_MARK + grid[i+1:]

That’s it, we have a size * size grid with 20% mines on it. We used set so while we generate random numbers, we can store them in the bombs set and we don’t have to care about duplications. It’s an easy way to generate N random numbers without duplications.

What next? We have to update all cells without a mine with a number that reflects how many adjacent mines it has.

So if our grid looks like this:

___
__B
B__

We want to update all cells to look like this:

_11
12B
B21

First real problem we just met, how to count them. Because we are using a single string to represent the while grid, the easiest way is to simply check all possible adjacent cells. For that (for readability) we can use a simple matrix-like array with all the possible cells relative to a given cell.

# (x, y)
search_matrix = [
        (-1, -1), (-1, 0), (-1, 1),
        ( 0, -1),          ( 0, 1),
        ( 1, -1), ( 1, 0), ( 1, 1)
        ]

Now we can simply iterate through our grid and we can calculate position with:

  • x2 = index + x1
  • y2 = index + y1 * size
for i, c in enumerate(grid):
  # if it's a bomb, we can skip
  if c == BOMB_MARK:
    continue
  mines_around = [grid[i+p+(q*size)] == BOMB_MARK for (p, q) in search_matrix]

It seems logical, but we will get a huge error when we are trying to reach indexes out of range, so we have to check if a given index is possible at all or not. We can introduce a simple function to check if it’s possible or not:

def possible(grid, i, p, q):
    v = i+p+(q*size)

    if v < 0 or v >= len(grid):
        return False

    return True

With this new function, we can fix out problem. At the same time we can convert our results to numbers:

for i, c in enumerate(grid):
  # if it's a bomb, we can skip
  if c == BOMB_MARK:
    continue
  mines_around = len(list(filter(None, [
      grid[i+p+(q*size)] == BOMB_MARK
          for (p, q) in search_matrix
          if possible(grid, i, p, q)])))
  # Update the cell if it's not 0
  if mines_around > 0:
      grid = grid[:i] + str(mines_around) + grid[i+1:]

It’s not obvious first, but this code still have a small bug. It counts adjacent cells through new-line, which is not the case we want, fortunately we can extend our possible function with a simple logic that checks if the calculated row is in the same row as our target row.

def possible(grid, i, p, q):
    v = i+p+(q*size)

    if v < 0 or v >= len(grid):
        return False

    return (v // size) == ((i + (q*size)) // size)

And now, we are ready to render our grid.

for i in range(0, len(grid), size):
    print("".join([hide(c) for c in grid[i:i+size]]))

Characters have no fixed width, we have to find something with fixed width. What can we use? Emojis:

  • bomb for a mine
  • white_large_square for an empty cell
  • one -> eight for numbers
# This function is here only because
# Hugo has no ability to disable emoji conversion per page
# and I did not find a better solution to write ": bomb :"
# without spaces and without auto-convert it to "💣"
def wrap(s):
  return f':{s}:'

def to_emoji(char):
    if char == BOMB_MARK:
        return wrap("bomb")
    if char == " ":
        return wrap("white_large_square")
    numbers = [
            "one", "two", "three",
            "four", "five", "six",
            "seven", "eight"
            ]
    return wrap(numbers[int(char)-1])

We are ready to render the final grid, and now we add a spam-text line too, to be sure it’s not messed up when we post it on Discord.

print("Shall we play a game?")

for i in range(0, len(grid), size):
    print("".join([hide(to_emoji(c)) for c in grid[i:i+size]]))

Full Source

import math
import random

def hide(content):
    return f'||{content}||'

def possible(grid, i, p, q):
    v = i+p+(q*size)

    if v < 0 or v >= len(grid):
        return False

    return (v // size) == ((i + (q*size)) // size)

# This function is here only because
# Hugo has no ability to disable emoji conversion per page
# and I did not find a better solution to write ": bomb :"
# without spaces and without auto-convert it to "💣"
def wrap(s):
  return f':{s}:'

def to_emoji(char):
    if char == BOMB_MARK:
        return wrap("bomb")
    if char == " ":
        return wrap("white_large_square")
    numbers = [
            "one", "two", "three",
            "four", "five", "six",
            "seven", "eight"
            ]
    return wrap(numbers[int(char)-1])

BOMB_MARK = 'X'
size = 8
number_of_bombs = math.ceil(size*size * 0.2)
grid = " " * size * size

bombs = set()
while len(bombs) < number_of_bombs:
    bombs.add(random.randint(0, size*size - 1))

for i in bombs:
    grid = grid[:i] + BOMB_MARK + grid[i+1:]

# (x, y)
search_matrix = [
        (-1, -1), (-1, 0), (-1, 1),
        ( 0, -1),          ( 0, 1),
        ( 1, -1), ( 1, 0), ( 1, 1)
        ]

for i, c in enumerate(grid):
    # if it's a bomb, we can skip
  if c == BOMB_MARK:
      continue
  mines_around = len(list(filter(None, [
      grid[i+p+(q*size)] == BOMB_MARK
      for (p, q) in search_matrix
      if possible(grid, i, p, q)])))
  # Update the cell if it's not 0
  if mines_around > 0:
      grid = grid[:i] + str(mines_around) + grid[i+1:]

print("Shall we play a game?")

for i in range(0, len(grid), size):
    print("".join([hide(to_emoji(c)) for c in grid[i:i+size]]))