Let's play bingo with JavaScript!
Fabian Cook
January 22, 2021

Let's play bingo with JavaScript!

Table of Contents

    Bingo is such a common game to be played, but the game provides a way to show off a few different techniques that we can use in JavaScript.

    The rules of bingo vary by region, but we generally follow these rules:

    • Each player can have one or more cards
    • Each card contains lines of numbers, ranging from 1 to 90
    • A caller announces numbers that have been selected by way of a random process
    • A number can only appear once for each game
    • Each number must have an equal chance of being selected
    • A player wins when they have made up a line of matched numbers
    • No two lines can contain the same set of numbers

    Lets start by generating a set of cards for players. We're going to write these functions within card.js, first we're going to create a list of numbers that we will be able to select from:

    export function createNumbers(minimum = 1, maximum = 90) {
      return Array.from({ length: maximum - minimum + 1 })
          .map((unused, index) => index + minimum);
    }

    Next we'll need a function to pick the next number:

    function selectNextNumber(availableNumbers) {
      const index = Math.round(Math.random() * (availableNumbers.length - 1));
      return availableNumbers[index];
    }

    We're going to make a generator function, which accepts the initial list of numbers, and yields the next number:

    export function* selectNumbers(availableNumbers = createNumbers()) {
      // Clone so that we're not modifying externally
      const numbers = [...availableNumbers];
      while (numbers.length) {
        const next = selectNextNumber(numbers),
              index = numbers.indexOf(next);
        // Remove the number so it can't be selected again
        numbers.splice(index, 1);
        // Provide both next, and the currently available numbers
        // We will need the numbers so we can back track when we come across
        // a duplicate!
        yield { next, remaining: [...numbers] };
      }
    }

    We're going to also need a way to verify that a line isn't a duplicate, we'll do this by sorting each line and seeing if there is any mismatch at said index, if there is a duplicate, then the line is invalid:

    function isLineValid(currentLines, line) {
        const sorted = [...line].sort();
        const foundIndex = currentLines.findIndex(
        currentLine => {
          if (currentLine.length !== sorted.length) {
               // How did we get a mismatch length?
            return false; 
          }
            const currentLineSorted = [...currentLine].sort();
          // Find the first mismatch
          const mismatchIndex = sorted.findIndex(
            (value, index) => currentLineSorted[index] !== value
          );
          // If none are mismatched
          return mismatchIndex === -1;
        }
      );
      // If nothing found matching, 
      return foundIndex === -1;
    }

    Now that we can select some numbers and verify a line, we'll need to create each line, we'll ask for the amount of numbers that are required, we'll need to know all the previous lines that have been generated, for all players, which we will use to ensure we're not doubling up on (by way of isLineValid)

    function selectLine(currentLines, generator, length) {
      const line = [];
      let remaining = undefined; 
      while (line.length < length) {
           const { value, done }  = generator.next(); 
        if (done) {
          // Was unable to complete the line!
             return undefined;  
        }
        const { next, remaining: currentRemaining } = value;
        remaining = currentRemaining;
        line.push(next);
      }
      if (isLineValid(currentLines, line)) {
        // Good to go!
           return line;
      }
      if (line.length === remaining.length) {
        // We need to start again completely...
          throw new Error("We've created a set of lines where we will always get a duplicate!");
      }
      // We need to roll back and make another line
      return selectLine(
        currentLines,
        selectNumbers(
          // Reset our numbers with the remaining and what we had used up
          [
            ...remaining,
            ...line
          ]
          .sort()
        ),
        length
      );
    }

    Now that we have a way to create lines, we'll be able to create a card:

    export function createCard(currentCards, rows, columns, numbers = createNumbers()) {
      const currentCardLines = currentCards.reduce(
        (lines, card) => lines.concat(card), []
      );
      const card = [],
          generator = selectNumbers(numbers);
      while (card.length < rows) {
           const line = selectLine([
          // Both current cards & new card lines are included
          ...currentCardLines,
          ...card
        ], generator, columns);
        if (!line) {
          // We've finished all our numbers, we need to abort!
          return undefined;
        }
        card.push(line);
      }
      return card;
    }

    Now when we run createCard, we're able to generate a set of numbers that we can use in our bingo game!

    $ npm i esm && node -r esm
    > import { createCard } from "./card";
    > createCard([], 5, 5);
    [ [ 48, 74, 3, 30, 4 ],
      [ 70, 11, 17, 62, 13 ],
      [ 9, 78, 32, 86, 40 ],
      [ 41, 68, 5, 52, 82 ],
      [ 16, 53, 57, 79, 23 ] ]

    We'll want a way to create a set amount of cards, so we'll provide a createCards function as well:

    export function createCards(amount, rows, columns, numbers = createNumbers()) {
        const cards = [];
      while (cards.length < amount) {
        const card = createCard(cards, rows, columns, numbers);
        if (!card) {
                break;
        }
        cards.push(card);
      }
      return cards;
    }

    We can test this by requesting 5 cards:

    $ node -r esm
    > import { createCards } from "./card";
    > createCards(5, 5, 5);
    [ [ [ 30, 23, 73, 62, 20 ],
        [ 84, 28, 38, 46, 77 ],
        [ 48, 34, 19, 29, 89 ],
        [ 21, 81, 37, 47, 72 ],
        [ 32, 50, 8, 61, 13 ] ],
      [ [ 51, 4, 75, 30, 33 ],
        [ 65, 13, 25, 67, 32 ],
        [ 39, 89, 9, 83, 18 ],
        [ 45, 37, 57, 6, 47 ],
        [ 62, 52, 49, 15, 81 ] ],
      [ [ 17, 73, 40, 33, 72 ],
        [ 48, 35, 3, 27, 57 ],
        [ 11, 90, 5, 51, 59 ],
        [ 19, 81, 50, 22, 56 ],
        [ 14, 37, 25, 66, 26 ] ],
      [ [ 49, 18, 75, 38, 68 ],
        [ 77, 30, 62, 8, 21 ],
        [ 34, 84, 58, 40, 16 ],
        [ 67, 83, 50, 76, 55 ],
        [ 4, 9, 82, 60, 86 ] ],
      [ [ 52, 40, 28, 85, 46 ],
        [ 39, 80, 71, 87, 23 ],
        [ 77, 35, 47, 34, 30 ],
        [ 88, 62, 5, 61, 33 ],
        [ 70, 54, 76, 44, 81 ] ] ]

    I've tested this with 1k cards with 9 rows and 10 columns, and it works beautifully, it just takes a bit of time when you start to get into the larger amount of cards!

    Now that we have a way to create the playable cards, we'll need a way to play the game, so first we'll create a function that checks a card for winning lines (within game.js):

    function isWinningLine(line, calledNumbers) {
      const missingIndex = line.findIndex(
        value => !calledNumbers.includes(value)
      );
      return line.length > 0 && missingIndex === -1;
    } 
    
    function getWinningLines(lines, calledNumbers) {
       return lines.filter(line => isWinningLine(line, calledNumbers));
    }

    Now, lets simulate some bingo!

    import { createNumbers, selectNumbers } from "./card.js"; // At the top of our file
    
    export function play(playableCards, numbers = createNumbers()) {
      const caller = selectNumbers(numbers),
            calledNumbers = [],
            rounds = [],
            availableLines = playableCards.reduce(
              (lines, card) => lines.concat(card),
              []
            );
         for(const { next } in caller) {
        // Add our new number
           calledNumbers.push(next);
          const winningLines = getWinningLines(availableLines, calledNumbers);
        rounds.push(winningLines);
        // Remove the lines that have already one
        winningLines
            .forEach(
              line => {
                const index = availableLines.indexOf(line);
              availableLines.splice(index, 1);
            }
          );
        if (!availableLines.length) {
          break; // Done
        }
      }
        return rounds;
    }

    Now, lets create some cards and play:

    $ node -r esm
    > import { createCards } from "./card";
    > import { play } from "./game";
    > const cards = createCards(5, 5, 5);
    > const rounds = play(cards); 
    > rounds
    [ ...66 items,
      [ [ 7, 78, 87, 38, 68 ] ],
      ...23 items
    ]

    With this we can see it took 66 rounds to reach a point where one card had a single line match! By adjusting the line length, and the amount of rows each card has, and the amount of players, we can adjust the chances of winning earlier:

    $ node -r esm
    > import { createCards } from "./card";
    > import { play } from "./game";
    > const cards = createCards(100, 9, 10);
    > const rounds = play(cards); 
    > rounds
    [ ...53 items,
      [ [ 31, 79, 38, 84, 81, 42, 80, 29, 75, 83 ] ],
      ...36 items 
    ]

    Using this code we should be able to create a full game of bingo that users can interact with, in the next article we're going to cover building a user interface that pairs up with this implementation where players can play together.

    Through creating a straightforward game like bingo we were able to show off a few different techniques, namely, using generators, generating lists of random numbers, using Array.prototype.reduce, and lightly touching on usage of ECMAScript modules.

    About the Author
    Fabian Cook

    Fabian Cook

    JavaScript Developer.

    The Web Dev Monthly

    Sign up for a free monthly scoop of news and features articles handpicked by our staff.

    Unsubscribe at any time. No hidden catch.