Skip to Main Content

I built a digital version of Black Hole game

Try the Black Hole game yourself.

If you have been reading my blog for a while or know me from elsewhere, you might have noticed that I absolutely love board games. Digital games have their benefits but nothing beats sitting down with friends, grabbing a couple of beers and playing games with physical cards, tokens and boards. Or in the case of my Minimal Travel Table Top Game Collection 2: Social Distancing Edition, playing alone with physical cards and tokens.

Board games are also great for practicing my software development skills. Every now and then, I pick a game I'm familiar with from the physical world and try to build a web version of it with the digital skills I have. Sometimes I even play those games with friends but most of the time, I just build them because I like writing code that implements board games.

I like implementing board games in digital format because they are relatively simple in comparison with digital games in general: everything is basically in 2D and almost any action can be reduced to click or drag-and-drop which means my web dev skills come in handy. They also have very well defined interactions between different items and the rules are written in a logical way which makes the transformation process from physical to digital interesting.

Black Hole

The other night, I was watching one of my favorite Youtube channels: Tom Scott and rewatching his Game On series. One of the games they play in that show is Black Hole, designed by Wal Joris. I really like that game because it's fast to learn and play but still provides interesting choices and strategies when playing with a friend.

So that night, I couldn't sleep so I decided to give it a go and see if I could implement the game in web world. Eventually, I want to make the game playable over the Internet but the first version is implementation of the mechanics and rules on a shared computer experience.

Animation of game play with blue and red player placing tokens on the board after each other

Here are the rules of the game:

  • There are 21 slots shaped in a form of a pyramid
  • Two players take turns and place numbered tokens starting from 1 and ending with 10 on empty slots in the board
  • At the end, one slot will be left empty and it becomes the black hole
  • Points are scored as a sum of tokens that are adjacent to the black hole
  • Player with least points wins the round

There are also some rule variants for the game where instead of placing the numbers from 1 to 10, the order of tokens is randomized. My version only deals with the base rules (at least for now).

Tech & Design

I had a pretty clear vision of how to implement this. I chose to use React since that's what I've used the most in the recent months and would have one less thing to worry. I decided to make each slot a component <Slot /> that would keep track of its own state (mainly color and number after a token has been placed).

All the following code examples are small pieces of the codebase and I have redacted some pieces of code for clarity. You can find all the code (which is MIT licensed) in my GitHub repository.

const Slot = ({ slot, onClick, gameStatus }) => {
  if (
    gameStatus === GAMESTATE.FINISHED ||
    slot.state !== SLOTSTATE.UNSELECTED
  ) {
    onClick = () => {};
  }
  const { idx, state, number, color, highlight } = slot;
  const [left, top] = calculatePosition(idx);
​
  // - - redacted styles definitions for brevity - -
​
  return (
    <button
      disabled={
        state !== SLOTSTATE.UNSELECTED || gameStatus === GAMESTATE.FINISHED
      }
      className="slot"
      style={style}
      onClick={() => onClick(slot)}
    >
      {state === SLOTSTATE.UNSELECTED ? "" : number}
    </button>
  );
};

Here's what the <Slot /> looks like. I'm not 100% sure if I need the overwriting of onClick on lines 2–7 as I do disable the button when selected or game ends but I put it there as a extra precaution.

Each slot is a HTML button that gets positioned absolute and calculatePosition function returns the correct position in regard of the button's index in the pyramid. Once Slot button is clicked, it will change its background color (style definitions redacted for clarity) according to the player and show the number of the token placed.

At the beginning of the game, each slot is generated with some hardcoded rules:

const createSlots = () => {
  let slots = [];
  let col = 0;
  let row = 0;
  for (let i = 0; i < 21; i++) {
    if (i === 6 || i === 11 || i === 15 || i === 18 || i === 20) {
      row += 1;
      col = 0;
    }
    const idx = [row, col];
​
    col += 1;
​
    slots.push({
      i,
      idx,
      state: SLOTSTATE.UNSELECTED,
      number: null,
      color: null,
      highlight: false,
    });
  }
  return slots;
};

On line 6, the breakpoints are when new row of the pyramid is started. Indexes for position calculation are stored as [row, col] and a slot object contains information about the number, color and current state. highlight is used when scoring to display adjacent slots to the black hole.

My biggest hurdle was figuring out the adjacency of two different slots. I even considered hardcoding those relationships into slots themselves but was finally able to make it universal:

const isAdjacent = (slotA, slotB) => {
  return (
    (isSameRow(slotA, slotB) && isNextToEachOtherOnSameRow(slotA, slotB)) ||
    (isAdjacentRow(slotA, slotB) && isSameColumn(slotA, slotB)) ||
    (isPreviousRow(slotA, slotB) && isNextColumn(slotA, slotB)) ||
    (isNextRow(slotA, slotB) && isPreviousColumn(slotA, slotB))
  );
};

There are four ways two slots can be adjacent:

  1. They are on the same row in columns next to each other (for example, [0,0] and [0,1]
  2. They are on adjacent rows in the same column (for example [0,0] and [1,0])
  3. Black hole is on a row above the other one and in column + 1 (for example, [1,2] and [0,3]
  4. Black hole is on a row below the other one and in column - 1 (for example, [1,2] and [2,1]

Even my inability to explain it above in a clear way is a good indicator of how much trouble I had with it.

Since the problem space is finite and small (21 slots and each slot compared against 20 other), I decided to hardcode all these comparisons into a test suite. For each slot index, I created a full test pattern against each other slot. I marked down all the ones I know should be adjacent and made the failing set a difference from the success set.

escribe("[0,0]", () => {
  it("should match correctly", () => {
    const empty = { idx: [0, 0] };
    const matches = [{ idx: [0, 1] }, { idx: [1, 0] }];
    const fails = all.filter((slot) => {
      const slotIdx = JSON.stringify(slot.idx);
      for (let i = 0; i < matches.length; i++) {
        if (slotIdx === JSON.stringify(matches[i].idx)) {
          return false;
        }
      }
      return true;
    });
​
    expect(fails.length).toEqual(21 - matches.length);
    matches.forEach((match) => {
      expect(isAdjacent(empty, match)).toEqual(true);
    });
    fails.forEach((fail) => {
      expect(isAdjacent(empty, fail)).toEqual(false);
    });
  });
});

In the above, all is an array of all slots. Now I have 21 test cases where each one of those 21 slots is compared against every other slot to make sure my comparison function does what I want it to do. Instead of trying to come up with corner cases to a challenge I already a bit struggled with, I went with full coverage to make sure my corner case selection wasn't flawed.

One last piece of code I want to show is how I handle the clicks and advancement of the gameplay.

const clickHandler = (slot) => {
    const newSlot = gameState.slots[slot.i];
    newSlot.number = gameState.currentValue;
    newSlot.color = gameState.currentPlayer;
    newSlot.state = SLOTSTATE.SELECTED;
​
    const newValue =
      gameState.currentPlayer === PLAYER.RED
        ? gameState.currentValue
        : gameState.currentValue + 1;
    const newPlayer =
      gameState.currentPlayer === PLAYER.RED ? PLAYER.BLUE : PLAYER.RED;
​
    let status = gameState.status;
    if (newValue === 11) {
      status = GAMESTATE.FINISHED;
    }
​
    setGameState({
      currentPlayer: newPlayer,
      currentValue: newValue,
      slots: gameState.slots,
      status,
    });
  };

Lines 2–5 are probable causes for some trouble down the line because they modify the state directly. I need to clean that up.

How it works is that there are three main elements in addition to the slots: currentPlayer, currentValue and status.

currentPlayer toggles between RED and BLUE and whenever it's been BLUE's turn, we also increment the currentValue to signify that the game has moved to the next token. And if the new value would reach 11, we set the status to FINISHED which triggers the calculation of points and turns off the ability to place tokens on the game board. Finally, a scoreboard is shown and the game ends. You can play another round by refreshing the page.

What's next?

The first version is very limited by design. I wanted to make something that works and push that to the world so at least I've been able to finish it if I don't find time or interest in the future for this particular game.

However, there are a couple of things I'd like to add:

  1. Online multiplayer: I want to make it possible to play with friends over the Internet and not just on the same computer.
  2. Second game mode: I mentioned earlier the randomized tokens version of this game.
  3. Change colors: Picking your own colors would be nice if red and blue are not good options for the player.
  4. Display of all 20 tokens on the side (as you would see the physical tokens on the board as well).
  5. Maybe some animations for placing a token.

My motivation to build it was to practice my basic programming skills and see what this game looks like when transforming the physical game into digital realm. For that, I reached my goal and I had a lot of fun. All the things above are small things I might work on when I feel like it.

And if you think implementing one of them would be a fun exercise, the code is on GitHub with open source license and I'm happy to welcome pull requests.